"""Declares the Job class used to run and track subprocess pipelines."""
import asyncio
import io
import os
import shlex
import signal

from .util import export
from .PipelineNode import PipelineNode

__all__ = []

@export
class NonzeroExitCodeException(Exception):
    """
    Raised when a process within an SHTK job exits with a nonzero return code.

    Args:
        processes (list): The PipelineProcess instances to include in the error
            message
    """
    def __init__(self, processes):
        self.processes = processes
        message = io.StringIO()
        print("One or more of the following processes returned non-zero return code:", file=message)
        for proc in self.processes:
            rc = proc.proc.returncode
            args = []
            for arg in proc.args:
                args.append(shlex.quote(str(arg)))

            print(f"  [{rc:3}] {' '.join(args)}", file=message)

        self.message = message.getvalue()
        message.close()

        super().__init__(self.message)


@export
class Job:
    """
    Instantiates PipelienNodeFactory instances to run subprocesses.

    Job objects instantiate a PipelineNodeFactory template to create
    PipelineNode's that run and track the progress of a command pipeline.

    Args:
        pipeline_factory (PipelineNodeFactory): The command pipeline template
            that will be instantiated by the Job instance.
        cwd (str or Pathlib.Path): The current working directory in which to
            run the pipeline processes.
        env (dict): The environment variables to pass to processes run within
            the pipleine
        event_loop (None or asyncio.AbstractEventLoop): The event loop to use
            for asyncio based processing.  If None is passed a new event loop
            is created from asyncio.new_event_loop() instead.
        user (None, int, or str): The user that will be used to setreuid() any
            child processes.  The behavior is the same as that of the user arg
            to subprocess.Popen().
        group (None, int, or str): The user that will be used to setregid() any
            child processes.  The behavior is the same as that of the group arg
            to subprocess.Popen().
        close_fds (bool): If true, close_fds will be passed to the equivalent
            of subprocess.Popen() (Default value = True).

    Attributes:
        cwd (str or Pathlib.Path): The current working directory in which to
            run the pipeline processes.
        environment (dict): The environment variables to pass to processes run
            within the pipleine
        event_loop (None or asyncio.AbstractEventLoop): The event loop to use
            for asyncio based processing.  If None is passed a new event loop
            is created from asyncio.new_event_loop() instead.
        pipeline (None, PipelineNode): The running PipelineNode generated by
            PipelineFactory.build in run().
        pipeline_factory (PipelineNodeFactory): The command pipeline template
            that will be instantiated by the Job instance.
        user (None, int, or str): The user that will be used to setreuid() any
            child processes.  The behavior is the same as that of the user arg
            to subprocess.Popen().
        group (None, int, or str): The user that will be used to setregid() any
            child processes.  The behavior is the same as that of the group arg
            to subprocess.Popen().
        close_fds (bool): If true, close_fds will be passed to the equivalent
            of subprocess.Popen().


    """
    def __init__(
            self, pipeline_factory, cwd=None, env=None, event_loop=None,
            user=None, group=None, close_fds=True
    ):
        if env is None:
            self.environment = {}
        else:
            self.environment = dict(env)

        self.pipeline_factory = pipeline_factory
        self.pipeline = None
        self.user = user
        self.group = group
        self.close_fds = close_fds

        if event_loop is None:
            self.event_loop = asyncio.new_event_loop()
        else:
            self.event_loop = event_loop

        if cwd is not None:
            self.cwd = cwd
        else:
            self.cwd = os.getcwd()

    @property
    def stdin(self):
        """
        Returns the pipeline's stdin_stream.writer(), or None

        Returns:
            Pipeline's stdin_stream.writer(), or None if no pipeline is
            running.
        """
        if self.pipeline is not None:
            return self.pipeline.stdin_stream.writer()
        else:
            return None

    @property
    def stdout(self):
        """
        Returns the pipeline's stdout_stream.reader(), or None

        Returns:
            Pipeline's stdout_stream.reader(), or None if no pipeline is
            running.
        """
        if self.pipeline is not None:
            return self.pipeline.stdout_stream.reader()
        else:
            return None

    @property
    def stderr(self):
        """
        Returns the pipeline's stderr_stream.reader(), or None

        Returns:
            Pipeline's stderr_stream.reader(), or None if no pipeline is
            running.
        """
        if self.pipeline is not None:
            return self.pipeline.stderr_stream.reader()
        else:
            return None

    async def run_async(self, stdin_factory, stdout_factory, stderr_factory):
        """
        Creates and runs a new pipeline

        Instantiates and runs a pipeline based on the PipelineNodeFactory
        provided to the Job's constructor.

        Args:
            stdin_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stdin stream.
            stdout_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stdout stream.
            stderr_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stderr stream.
        """

        if self.pipeline is not None:
            raise RuntimeError("Jobs can only be run once.  Use retry() to run the job again.")

        stdin_stream = stdin_factory.build(self)
        stdout_stream = stdout_factory.build(self)
        stderr_stream = stderr_factory.build(self)

        self.pipeline = await self.pipeline_factory.build(
            self,
            stdin_stream=stdin_stream,
            stdout_stream=stdout_stream,
            stderr_stream=stderr_stream
        )

        stdin_stream.close()
        stdout_stream.close()
        stderr_stream.close()

        if self.pipeline.stdin_stream is not None:
            self.pipeline.stdin_stream.close()

        if self.pipeline.stdout_stream is not None:
            self.pipeline.stdout_stream.close()

        if self.pipeline.stderr_stream is not None:
            self.pipeline.stderr_stream.close()

    def run(self, stdin_factory, stdout_factory, stderr_factory):
        """
        Creates and runs a new pipeline

        Synchronously wrapper for run_async.

        Args:
            stdin_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stdin stream.
            stdout_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stdout stream.
            stderr_factory (StreamFactory): the StreamFactory to instantiate to
                create the job's default stderr stream.

        """
        return self.event_loop.run_until_complete(
            self.run_async(
                stdin_factory=stdin_factory,
                stdout_factory=stdout_factory,
                stderr_factory=stderr_factory
            )
        )

    async def wait_async(self, pipeline_node=None, exceptions=True):
        """
        Waits for all processes in the pipeline to complete

        Waits for all processes in the pipleine to complete checks the return
        codes of each command.

        Args:
            pipeline_node (PipelineNode or None): The pipeline node to wait for
            exceptions (Boolean): When true returns an exception when processes
                exit with non-zero return codes

        Returns:
            A tuple of exit codes from the completed proceses

        Raises:
            NonzeroExitCodeException: When a process returns a non-zero return code
            RuntimeError: When called on a Job that has not invoked Job.run()

        """

        if pipeline_node is None:
            if self.pipeline is None:
                raise RuntimeError("Cannot wait for a Job that has not yet called Job.run()")
            pipeline_node = self.pipeline
        elif not isinstance(pipeline_node, PipelineNode):
            raise ValueError("Argument pipeline_node must be a PipelineNode instance or None")

        ret = await pipeline_node.wait_async()

        if exceptions:
            if any(rc != 0 for rc in ret):
                raise NonzeroExitCodeException(x for x in pipeline_node.flatten_children())

        return tuple(ret)

    def wait(self, pipeline_node=None, exceptions=True):
        """
        Synchronous wrapper for the wait_async() method.

        Waits for all processes in the pipleine to complete checks the return
        codes of each command.

        Args:
            pipeline_node (PipelineNode or None): The pipeline node to wait for
            exceptions (Boolean): When true returns an exception when processes
                exit with non-zero return codes

        Returns:
            A tuple of exit codes from the completed proceses

        Raises:
            NonzeroExitCodeException: When a process returns a non-zero return code

        """

        return self.event_loop.run_until_complete(
            self.wait_async(pipeline_node=pipeline_node, exceptions=exceptions)
        )

    def send_signal(self, signum): #pylint: disable=no-self-use
        """
        Sends a signal to all child ProcessNode processes.

        Args:
            signum (int): the signal to send.
        """
        self.pipeline.send_signal(signum)

    def terminate(self):
        """
        Sends a signal.SIGTERM to all child ProcessNode processes.
        """
        self.send_signal(signal.SIGTERM)

    def kill(self):
        """
        Sends a signal.SIGKILL to all child ProcessNode processes.
        """
        self.send_signal(signal.SIGKILL)
