import subprocess
import time
import os
import shlex
import logging

from mini_buildd import config, util

LOG = logging.getLogger(__name__)


def taint_env(taint):
    env = os.environ.copy()
    for name in taint:
        env[name] = taint[name]
    return env


class Call():
    """
    Wrapper around python subprocess

    When supplying ``stdout`` or ``stderr``, provide raw and
    'seekable' file-like object; i.e., use "w+" and standard
    python ``open`` like::

      mystdout = open(myoutputfile, "w+")

    >>> Call(["echo", "-n", "hallo"]).check().stdout
    'hallo'

    >>> Call(["ls", "__no_such_file__"]).check()  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    util.HTTPBadRequest: Internal call failed (HTTP 400 Bad request syntax or unsupported method)

    >>> Call(["printf stdin; printf stderr >&2"], stderr=subprocess.STDOUT, shell=True).stdout
    'stdinstderr'
    """

    @classmethod
    def _call2shell(cls, call):
        """
        Convert call to human-readable shell line

        Converts an argument sequence ("call") to a
        command line more human-readable and "likely-suitable"
        for cut and paste to a shell.
        """
        return shlex.join(call)

    def __init__(self, call, run_as_root=False, **kwargs):
        self.call = ["sudo", "-n"] + call if run_as_root else call
        self.kwargs = kwargs.copy()

        # Generate stdout and stderr args if not given explicitly, and remember streams given explicitly
        self._given_stream = {}
        for stream in ["stdout", "stderr"]:
            if stream not in self.kwargs:
                self.kwargs[stream] = subprocess.PIPE
            elif not isinstance(self.kwargs[stream], int):
                self._given_stream[stream] = self.kwargs[stream]

        self.result = subprocess.run(self.call, **self.kwargs, check=False)

        # Convenience 'label' for log output
        self.label = f"{'#' if run_as_root else '?'} {call[0]}.."

        LOG.debug(f"Called with returncode {self.result.returncode}: {self._call2shell(self.call)}")

    @classmethod
    def _bos2str(cls, value, errors="replace"):
        """Return str(), regardless if value is of type bytes or str"""
        return value if isinstance(value, str) else value.decode(encoding=config.CHAR_ENCODING, errors=errors)

    def _stdx(self, value, key):
        """Stdin or stdout value as str"""
        if value:
            return self._bos2str(value)
        if key in self._given_stream:
            self._given_stream[key].seek(0)
            return self._bos2str(self._given_stream[key].read())
        return ""

    @property
    def stdout(self):
        """Stdout value (empty string if none)"""
        return self._stdx(self.result.stdout, "stdout")

    @property
    def stderr(self):
        """Stderr value (empty string if none)"""
        return self._stdx(self.result.stderr, "stderr")

    def log(self, level=None):
        """
        Log calls output to mini-buildd's logging for debugging

        Per default, this logs to level ``error`` on failure, ``debug`` on success.
        """
        level = level if level is not None else logging.DEBUG if self.result.returncode == 0 else logging.ERROR
        for prefix, output in [("stdout", self.stdout), ("stderr", self.stderr)]:
            for line in output.splitlines():
                LOG.log(level, f"{self.label} ({prefix}): {line}")
        return self

    def success(self):
        return self.result.returncode == 0

    def check(self, public_message="Internal call failed"):
        """Raise on unsuccessful (returncode != 0) call"""
        if not self.success():
            self.log()
            raise util.HTTPBadRequest(public_message)
        return self


def call_sequence(calls, run_as_root=False, rollback_only=False, **kwargs):
    """
    Run sequences of calls with rollback support

    >>> call_sequence([(["echo", "-n", "cmd0"], ["echo", "-n", "rollback cmd0"])])
    >>> call_sequence([(["echo", "cmd0"], ["echo", "rollback cmd0"])], rollback_only=True)
    """
    def rollback(pos):
        for i in range(pos, -1, -1):
            if calls[i][1]:
                Call(calls[i][1], run_as_root=run_as_root, **kwargs).log()
            else:
                LOG.debug(f"Skipping empty rollback call sequent {i}")

    if rollback_only:
        rollback(len(calls) - 1)
    else:
        i = 0
        try:
            for c in calls:
                if c[0]:
                    Call(c[0], run_as_root=run_as_root, **kwargs).check()
                else:
                    LOG.debug(f"Skipping empty call sequent {i}")
                i += 1
        except BaseException:
            LOG.error(f"Sequence failed at: {i} (rolling back)")
            rollback(i)
            raise


def call_with_retry(call, retry_max_tries=5, retry_sleep=1, retry_failed_cleanup=None, **kwargs):
    """
    Run call repeatedly until it succeeds (retval 0)

    In case retry_max_tries is reached, the error from the last try is raised.

    >>> call_with_retry(["/bin/true"])
    >>> call_with_retry(["/bin/false"])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
      ...
    util.HTTPBadRequest: Internal call failed (HTTP 400 Bad request syntax or unsupported method)
    """
    for t in range(retry_max_tries):
        try:
            Call(call, **kwargs).check()
            return
        except BaseException as e:
            if t >= retry_max_tries - 1:
                raise
            LOG.warning(f"Retrying call in {retry_sleep} seconds [retry #{t}]: {e}")
            if retry_failed_cleanup is not None:
                retry_failed_cleanup()
            time.sleep(retry_sleep)
