#
#  Copyright (C) 2017 Codethink Limited
#
#  This program 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 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.
#
#  You should have received a copy of the GNU Lesser General Public
#  License along with this library. If not, see <http://www.gnu.org/licenses/>.
#
#  Authors:
#        Tristan Maat <tristan.maat@codethink.co.uk>
#        Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>

import os
import sys
import stat
import signal
import subprocess
from contextlib import contextmanager, ExitStack
import psutil

from .._exceptions import SandboxError
from .. import utils
from .. import _signals
from ._mounter import Mounter
from ._mount import MountMap
from . import Sandbox, SandboxFlags


class SandboxChroot(Sandbox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        uid = self._get_config().build_uid
        gid = self._get_config().build_gid
        if uid != 0 or gid != 0:
            raise SandboxError("Chroot sandboxes cannot specify a non-root uid/gid "
                               "({},{} were supplied via config)".format(uid, gid))

        self.mount_map = None

    def run(self, command, flags, *, cwd=None, env=None):

        # Default settings
        if cwd is None:
            cwd = self._get_work_directory()

        if cwd is None:
            cwd = '/'

        if env is None:
            env = self._get_environment()

        if not self._has_command(command[0], env):
            raise SandboxError("Staged artifacts do not provide command "
                               "'{}'".format(command[0]),
                               reason='missing-command')

        # Command must be a list
        if isinstance(command, str):
            command = [command]

        stdout, stderr = self._get_output()

        # Create the mount map, this will tell us where
        # each mount point needs to be mounted from and to
        self.mount_map = MountMap(self, flags & SandboxFlags.ROOT_READ_ONLY)
        root_mount_source = self.mount_map.get_mount_source('/')

        # Create a sysroot and run the command inside it
        with ExitStack() as stack:
            os.makedirs('/var/run/buildstream', exist_ok=True)

            # FIXME: While we do not currently do anything to prevent
            # network access, we also don't copy /etc/resolv.conf to
            # the new rootfs.
            #
            # This effectively disables network access, since DNs will
            # never resolve, so anything a normal process wants to do
            # will fail. Malicious processes could gain rights to
            # anything anyway.
            #
            # Nonetheless a better solution could perhaps be found.

            rootfs = stack.enter_context(utils._tempdir(dir='/var/run/buildstream'))
            stack.enter_context(self.create_devices(self.get_directory(), flags))
            stack.enter_context(self.mount_dirs(rootfs, flags, stdout, stderr))

            if flags & SandboxFlags.INTERACTIVE:
                stdin = sys.stdin
            else:
                stdin = stack.enter_context(open(os.devnull, 'r'))  # pylint: disable=unspecified-encoding

            # Ensure the cwd exists
            if cwd is not None:
                workdir = os.path.join(root_mount_source, cwd.lstrip(os.sep))
                os.makedirs(workdir, exist_ok=True)

            status = self.chroot(rootfs, command, stdin, stdout,
                                 stderr, cwd, env, flags)

        return status

    # chroot()
    #
    # A helper function to chroot into the rootfs.
    #
    # Args:
    #    rootfs (str): The path of the sysroot to chroot into
    #    command (list): The command to execute in the chroot env
    #    stdin (file): The stdin
    #    stdout (file): The stdout
    #    stderr (file): The stderr
    #    cwd (str): The current working directory
    #    env (dict): The environment variables to use while executing the command
    #    flags (:class:`SandboxFlags`): The flags to enable on the sandbox
    #
    # Returns:
    #    (int): The exit code of the executed command
    #
    def chroot(self, rootfs, command, stdin, stdout, stderr, cwd, env, flags):
        # pylint: disable=subprocess-popen-preexec-fn

        def kill_proc():
            if process:
                # First attempt to gracefully terminate
                proc = psutil.Process(process.pid)
                proc.terminate()

                try:
                    proc.wait(20)
                except psutil.TimeoutExpired:
                    utils._kill_process_tree(process.pid)

        def suspend_proc():
            group_id = os.getpgid(process.pid)
            os.killpg(group_id, signal.SIGSTOP)

        def resume_proc():
            group_id = os.getpgid(process.pid)
            os.killpg(group_id, signal.SIGCONT)

        try:
            with _signals.suspendable(suspend_proc, resume_proc), _signals.terminator(kill_proc):
                process = subprocess.Popen(  # pylint: disable=consider-using-with
                    command,
                    close_fds=True,
                    cwd=os.path.join(rootfs, cwd.lstrip(os.sep)),
                    env=env,
                    stdin=stdin,
                    stdout=stdout,
                    stderr=stderr,
                    # If you try to put gtk dialogs here Tristan (either)
                    # will personally scald you
                    preexec_fn=lambda: (os.chroot(rootfs), os.chdir(cwd)),
                    start_new_session=flags & SandboxFlags.INTERACTIVE
                )

                # Wait for the child process to finish, ensuring that
                # a SIGINT has exactly the effect the user probably
                # expects (i.e. let the child process handle it).
                try:
                    while True:
                        try:
                            _, status = os.waitpid(process.pid, 0)
                            # If the process exits due to a signal, we
                            # brutally murder it to avoid zombies
                            if not os.WIFEXITED(status):
                                utils._kill_process_tree(process.pid)

                        # Unlike in the bwrap case, here only the main
                        # process seems to receive the SIGINT. We pass
                        # on the signal to the child and then continue
                        # to wait.
                        except KeyboardInterrupt:
                            process.send_signal(signal.SIGINT)
                            continue

                        break
                # If we can't find the process, it has already died of
                # its own accord, and therefore we don't need to check
                # or kill anything.
                except psutil.NoSuchProcess:
                    pass

                # Return the exit code - see the documentation for
                # os.WEXITSTATUS to see why this is required.
                if os.WIFEXITED(status):
                    code = os.WEXITSTATUS(status)
                else:
                    code = -1

        except subprocess.SubprocessError as e:
            # Exceptions in preexec_fn are simply reported as
            # 'Exception occurred in preexec_fn', turn these into
            # a more readable message.
            if '{}'.format(e) == 'Exception occurred in preexec_fn.':
                raise SandboxError('Could not chroot into {} or chdir into {}. '
                                   'Ensure you are root and that the relevant directory exists.'
                                   .format(rootfs, cwd)) from e
            raise SandboxError('Could not run command {}: {}'.format(command, e)) from e

        return code

    # create_devices()
    #
    # Create the nodes in /dev/ usually required for builds (null,
    # none, etc.)
    #
    # Args:
    #    rootfs (str): The path of the sysroot to prepare
    #    flags (:class:`.SandboxFlags`): The sandbox flags
    #
    @contextmanager
    def create_devices(self, rootfs, flags):

        devices = []
        # When we are interactive, we'd rather mount /dev due to the
        # sheer number of devices
        if not flags & SandboxFlags.INTERACTIVE:

            for device in Sandbox.DEVICES:
                location = os.path.join(rootfs, device.lstrip(os.sep))
                os.makedirs(os.path.dirname(location), exist_ok=True)
                try:
                    if os.path.exists(location):
                        os.remove(location)

                    devices.append(self.mknod(device, location))
                except OSError as e:
                    if e.errno == 1:
                        raise SandboxError("Permission denied while creating device node: {}.".format(e) +
                                           "BuildStream reqiures root permissions for these setttings.") from e
                    raise

        yield

        for device in devices:
            os.remove(device)

    # mount_dirs()
    #
    # Mount paths required for the command.
    #
    # Args:
    #    rootfs (str): The path of the sysroot to prepare
    #    flags (:class:`.SandboxFlags`): The sandbox flags
    #    stdout (file): The stdout
    #    stderr (file): The stderr
    #
    @contextmanager
    def mount_dirs(self, rootfs, flags, stdout, stderr):

        # FIXME: This should probably keep track of potentially
        #        already existing files a la _sandboxwrap.py:239

        @contextmanager
        def mount_point(point, **kwargs):
            mount_source_overrides = self._get_mount_sources()
            mount_source = mount_source_overrides.get(point, self.mount_map.get_mount_source(point))
            mount_point = os.path.join(rootfs, point.lstrip(os.sep))

            with Mounter.bind_mount(mount_point, src=mount_source, stdout=stdout, stderr=stderr, **kwargs):
                yield

        @contextmanager
        def mount_src(src, **kwargs):
            mount_point = os.path.join(rootfs, src.lstrip(os.sep))
            os.makedirs(mount_point, exist_ok=True)

            with Mounter.bind_mount(mount_point, src=src, stdout=stdout, stderr=stderr, **kwargs):
                yield

        with ExitStack() as stack:
            stack.enter_context(self.mount_map.mounted(self))

            stack.enter_context(mount_point('/'))

            if flags & SandboxFlags.INTERACTIVE:
                stack.enter_context(mount_src('/dev'))

            stack.enter_context(mount_src('/tmp'))
            stack.enter_context(mount_src('/proc'))

            for mark in self._get_marked_directories():
                stack.enter_context(mount_point(mark['directory']))

            # Remount root RO if necessary
            if flags & flags & SandboxFlags.ROOT_READ_ONLY:
                root_mount = Mounter.mount(rootfs, stdout=stdout, stderr=stderr, remount=True, ro=True, bind=True)
                # Since the exit stack has already registered a mount
                # for this path, we do not need to register another
                # umount call.
                root_mount.__enter__()

            yield

    # mknod()
    #
    # Create a device node equivalent to the given source node
    #
    # Args:
    #    source (str): Path of the device to mimic (e.g. '/dev/null')
    #    target (str): Location to create the new device in
    #
    # Returns:
    #    target (str): The location of the created node
    #
    def mknod(self, source, target):
        try:
            dev = os.stat(source)
            major = os.major(dev.st_rdev)
            minor = os.minor(dev.st_rdev)

            target_dev = os.makedev(major, minor)

            os.mknod(target, mode=stat.S_IFCHR | dev.st_mode, device=target_dev)

        except PermissionError as e:
            raise SandboxError('Could not create device {}, ensure that you have root permissions: {}') from e

        except OSError as e:
            raise SandboxError('Could not create device {}: {}'
                               .format(target, e)) from e

        return target
