import os
import signal
import subprocess


class TaskRunCommandMixin:
    """
    Mixin to run commands.

    Use process groups to make sure that the command and possible spawned
    commands are finished and if Task.aborted() is True cancels the
    execution of the command.
    """

    def run_cmd(self, cmd):
        """Execute cmd. If Task.cancelled == True terminates the process."""
        p = subprocess.Popen(cmd, start_new_session=True)
        process_group = os.getpgid(p.pid)

        while True:
            if self.aborted:
                break

            try:
                self._wait_popen(p, timeout=1)
                break
            except subprocess.TimeoutExpired:
                pass

        if self.aborted:
            self.logger.debug("Task (cmd: %s PID %s) aborted", cmd, p.pid)
            try:
                if not self._send_signal_pid(p.pid, signal.SIGTERM):
                    # _send_signal_pid failed probably because cmd finished
                    # after aborting and before sending the signal
                    #
                    # p.poll() to read the returncode and avoid leaving cmd
                    # as zombie
                    p.poll()

                    # Kill possible processes launched by cmd
                    self._send_signal_group(process_group, signal.SIGKILL)
                    self.logger.debug("Could not send SIGTERM to %s", p.pid)
                    return False

                # _wait_popen with a timeout=5 to leave 5 seconds of grace
                # for the cmd to finish after sending SIGTERM
                self._wait_popen(p, timeout=5)
            except subprocess.TimeoutExpired:
                # SIGTERM was sent and 5 seconds later cmd
                # was still running. A SIGKILL to the process group will
                # be sent
                self.logger.debug(
                    "Task PID %s not finished after SIGTERM", p.pid
                )
                pass

            # debusine sends a SIGKILL if:
            # - SIGTERM was sent to cmd AND cmd was running 5 seconds later:
            #   SIGTERM was not enough so SIGKILL to the group is needed
            # - SIGTERM was sent to cmd AND cmd finished: SIGKILL to the
            #   group to make sure that there are not processes spawned
            #   by cmd running
            # (note that a cmd could launch processes in a new group
            # could be left running)
            self._send_signal_group(process_group, signal.SIGKILL)
            self.logger.debug("Sent SIGKILL to process group %s", process_group)

            # p.poll() to set p.returncode and avoid leaving cmd
            # as a zombie process.
            # But cmd might be left as a zombie process: if cmd was in a
            # non-interruptable kernel call p.returncode will be None even
            # after p.poll() and it will be left as a zombie process
            # (until debusine worker dies and the zombie is adopted by
            # init and waited on by init). If this happened there we might be a
            # ResourceWarning from Popen.__del__:
            # "subprocess %s is still running"
            #
            # A solution would e to wait (p.waitpid()) that the
            # process finished dying. This is implemented in the unit test
            # to avoid the warning but not implemented here to not delay
            # the possible shut down of debusine worker
            p.poll()
            self.logger.debug("Returncode for PID %s: %s", p.pid, p.returncode)
            return False
        else:
            # The cmd has finished. The cmd might have spawned
            # other processes. debusine will kill any alive processes.
            #
            # If they existed they should have been finished by cmd:
            # run_cmd() should not leave processes behind.
            #
            # Since the parent died they are adopted by init and on
            # killing them they are not zombie.
            # (cmd might have spawned new processes in a different process
            # group: if this is the case they will be left running)
            self._send_signal_group(process_group, signal.SIGKILL)

            return p.returncode == 0

    def _wait_popen(self, popen, timeout):
        return popen.wait(timeout)

    @staticmethod
    def _send_signal_pid(pid, signal) -> bool:
        try:
            os.kill(pid, signal)
        except ProcessLookupError:
            return False

        return True

    @staticmethod
    def _send_signal_group(process_group, signal):
        """Send signal to the process group."""
        try:
            os.killpg(process_group, signal)
        except ProcessLookupError:
            pass
