# backend.py - execute rendering, open files in viewer

import os
import io
import re
import sys
import errno
import platform
import subprocess
import contextlib

from ._compat import stderr_write_binary

from . import tools

__all__ = ['render', 'pipe', 'version', 'view']

ENGINES = {  # http://www.graphviz.org/pdf/dot.1.pdf
    'dot', 'neato', 'twopi', 'circo', 'fdp', 'sfdp', 'patchwork', 'osage',
}

FORMATS = {  # http://www.graphviz.org/doc/info/output.html
    'bmp',
    'canon', 'dot', 'gv', 'xdot', 'xdot1.2', 'xdot1.4',
    'cgimage',
    'cmap',
    'eps',
    'exr',
    'fig',
    'gd', 'gd2',
    'gif',
    'gtk',
    'ico',
    'imap', 'cmapx',
    'imap_np', 'cmapx_np',
    'ismap',
    'jp2',
    'jpg', 'jpeg', 'jpe',
    'json', 'json0', 'dot_json', 'xdot_json',  # Graphviz 2.40
    'pct', 'pict',
    'pdf',
    'pic',
    'plain', 'plain-ext',
    'png',
    'pov',
    'ps',
    'ps2',
    'psd',
    'sgi',
    'svg', 'svgz',
    'tga',
    'tif', 'tiff',
    'tk',
    'vml', 'vmlz',
    'vrml',
    'wbmp',
    'webp',
    'xlib',
    'x11',
}

PLATFORM = platform.system().lower()

POPEN_KWARGS = {}

if PLATFORM == 'windows':  # pragma: no cover
    POPEN_KWARGS['startupinfo'] = subprocess.STARTUPINFO()
    POPEN_KWARGS['startupinfo'].dwFlags |= subprocess.STARTF_USESHOWWINDOW
    POPEN_KWARGS['startupinfo'].wShowWindow = subprocess.SW_HIDE
    # work around WinError 87 from https://bugs.python.org/issue19764
    # https://github.com/python/cpython/commit/b2a6083eb0384f38839d3f1ed32262a3852026fa
    # TODO: consider not reusing the instance instead (adapt test code for this)
    if sys.version_info >= (3, 7):
        POPEN_KWARGS['close_fds'] = False


class ExecutableNotFound(RuntimeError):
    """Exception raised if the Graphviz executable is not found."""

    _msg = ('failed to execute %r, '
            'make sure the Graphviz executables are on your systems\' PATH')

    def __init__(self, args):
        super(ExecutableNotFound, self).__init__(self._msg % args)


def command(engine, format, filepath=None):
    """Return args list for ``subprocess.Popen`` and name of the rendered file."""
    if engine not in ENGINES:
        raise ValueError('unknown engine: %r' % engine)
    if format not in FORMATS:
        raise ValueError('unknown format: %r' % format)

    args, rendered = [engine, '-T%s' % format], None
    if filepath is not None:
        args.extend(['-O', filepath])
        rendered = '%s.%s' % (filepath, format)

    return args, rendered


def render(engine, format, filepath, quiet=False):
    """Render file with Graphviz ``engine`` into ``format``,  return result filename.

    Args:
        engine: The layout commmand used for rendering (``'dot'``, ``'neato'``, ...).
        format: The output format used for rendering (``'pdf'``, ``'png'``, ...).
        filepath: Path to the DOT source file to render.
        quiet (bool): Suppress ``stderr`` output on non-zero exit status.
    Returns:
        The (possibly relative) path of the rendered file.
    Raises:
        ValueError: If ``engine`` or ``format`` are not known.
        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
        subprocess.CalledProcessError: If the exit status is non-zero.
    """
    args, rendered = command(engine, format, filepath)

    if quiet:
        open = io.open
    else:
        @contextlib.contextmanager
        def open(name, mode):
            assert name == os.devnull and mode == 'w'
            yield None

    with open(os.devnull, 'w') as stderr:
        try:
            subprocess.check_call(args, stderr=stderr, **POPEN_KWARGS)
        except OSError as e:
            if e.errno == errno.ENOENT:
                raise ExecutableNotFound(args)
            else:  # pragma: no cover
                raise

    return rendered


def pipe(engine, format, data, quiet=False):
    """Return ``data`` piped through Graphviz ``engine`` into ``format``.

    Args:
        engine: The layout commmand used for rendering (``'dot'``, ``'neato'``, ...).
        format: The output format used for rendering (``'pdf'``, ``'png'``, ...).
        data: The binary (encoded) DOT source string to render.
        quiet (bool): Suppress ``stderr`` output on non-zero exit status.
    Returns:
        Binary (encoded) stdout of the layout command.
    Raises:
        ValueError: If ``engine`` or ``format`` are not known.
        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
        subprocess.CalledProcessError: If the exit status is non-zero.
    """
    args, _ = command(engine, format)

    try:
        proc = subprocess.Popen(args, stdin=subprocess.PIPE,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            **POPEN_KWARGS)
    except OSError as e:
        if e.errno == errno.ENOENT:
            raise ExecutableNotFound(args)
        else:  # pragma: no cover
            raise

    outs, errs = proc.communicate(data)
    if proc.returncode:
        if not quiet:
            stderr_write_binary(errs)
            sys.stderr.flush()
        raise subprocess.CalledProcessError(proc.returncode, args, output=outs)

    return outs


def version():
    """Return the version number tuple from the ``stderr`` output of ``dot -V``.

    Returns:
        Two or three ``int`` version ``tuple``.
    Raises:
        graphviz.ExecutableNotFound: If the Graphviz executable is not found.
        subprocess.CalledProcessError: If the exit status is non-zero.
        RuntimmeError: If the output cannot be parsed into a version number.
    """
    args = ['dot', '-V']
    try:
        outs = subprocess.check_output(args, stderr=subprocess.STDOUT,
                                       **POPEN_KWARGS)
    except OSError as e:
        if e.errno == errno.ENOENT:
            raise ExecutableNotFound(args)
        else:  # pragma: no cover
            raise

    info = outs.decode('ascii')
    ma = re.search(r'graphviz version (\d+\.\d+(?:\.\d+)?) ', info)
    if ma is None:
        raise RuntimeError
    return tuple(int(d) for d in ma.group(1).split('.'))


def view(filepath):
    """Open filepath with its default viewing application (platform-specific).

    Args:
        filepath: Path to the file to open in viewer.
    Raises:
        RuntimeError: If the current platform is not supported.
    """
    try:
        view_func = getattr(view, PLATFORM)
    except AttributeError:
        raise RuntimeError('platform %r not supported' % PLATFORM)
    view_func(filepath)


@tools.attach(view, 'darwin')
def view_darwin(filepath):
    """Open filepath with its default application (mac)."""
    subprocess.Popen(['open', filepath])


@tools.attach(view, 'linux')
@tools.attach(view, 'freebsd')
def view_unixoid(filepath):
    """Open filepath in the user's preferred application (linux, freebsd)."""
    subprocess.Popen(['xdg-open', filepath])


@tools.attach(view, 'windows')
def view_windows(filepath):
    """Start filepath with its associated application (windows)."""
    os.startfile(os.path.normpath(filepath))
