# Copyright (c) 2011 OpenStack Foundation
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""
Manages packages on the Guest VM.
"""
import os
import re
import subprocess
from tempfile import NamedTemporaryFile

from oslo_log import log as logging
from oslo_utils import encodeutils
import pexpect

from trove.common import exception
from trove.common.exception import ProcessExecutionError
from trove.common.i18n import _
from trove.common import utils
from trove.guestagent.common import operating_system


LOG = logging.getLogger(__name__)
OK = 0
RUN_DPKG_FIRST = 1
REINSTALL_FIRST = 2
CONFLICT_REMOVED = 3


def getoutput(*cmd):
    """Get the stdout+stderr of a command, ignore errors.

    Similar to commands.getstatusoutput(cmd)[1] of Python 2.
    """

    try:
        proc = subprocess.Popen(cmd,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.STDOUT)
    except OSError:
        # ignore errors like program not found
        return str("")

    stdout = proc.communicate()[0]
    return encodeutils.safe_decode(stdout)


class PkgAdminLockError(exception.TroveError):
    pass


class PkgPermissionError(exception.TroveError):
    pass


class PkgPackageStateError(exception.TroveError):
    pass


class PkgNotFoundError(exception.NotFound):
    pass


class PkgTimeout(exception.TroveError):
    pass


class PkgScriptletError(exception.TroveError):
    pass


class PkgDownloadError(exception.TroveError):
    pass


class PkgSignError(exception.TroveError):
    pass


class PkgBrokenError(exception.TroveError):
    pass


class PkgConfigureError(exception.TroveError):
    pass


class BasePackagerMixin(object):

    def pexpect_kill_proc(self, child):
        child.delayafterclose = 1
        child.delayafterterminate = 1
        try:
            child.close(force=True)
        except pexpect.ExceptionPexpect:
            # Close fails to terminate a sudo process on some OSes.
            subprocess.call(['sudo', 'kill', str(child.pid)])

    def pexpect_wait_and_close_proc(self, child):
        child.expect(pexpect.EOF)
        child.close()

    def pexpect_run(self, cmd, output_expects, time_out):
        child = pexpect.spawn(cmd, timeout=time_out)
        try:
            i = child.expect(output_expects)
            match = child.match
            self.pexpect_wait_and_close_proc(child)
        except pexpect.TIMEOUT:
            self.pexpect_kill_proc(child)
            raise PkgTimeout(_("Process timeout after %i seconds.") % time_out)
        return (i, match)


class RPMPackagerMixin(BasePackagerMixin):

    def _rpm_remove_nodeps(self, package_name):
        """
        Sometimes transaction errors happens, easy way is to remove
        conflicted package without dependencies and hope it will replaced
        by another package
        """
        try:
            utils.execute("rpm", "-e", "--nodeps", package_name,
                          run_as_root=True, root_helper="sudo")
        except ProcessExecutionError:
            LOG.exception("Error removing conflict %(package)s",
                          package_name)

    def _install(self, packages, time_out):
        """must be overridden by an RPM based PackagerMixin"""
        raise NotImplementedError()

    def _remove(self, package_name, time_out):
        """must be overridden by an RPM based PackagerMixin"""
        raise NotImplementedError()

    def pkg_install(self, packages, config_opts, time_out):
        result = self._install(packages, time_out)
        if result != OK:
            while result == CONFLICT_REMOVED:
                result = self._install(packages, time_out)
            if result != OK:
                raise PkgPackageStateError(_("Cannot install packages."))

    def pkg_is_installed(self, packages):
        packages = packages if isinstance(packages, list) else packages.split()
        std_out = getoutput("rpm", "-qa")
        for pkg in packages:
            found = False
            for line in std_out.split("\n"):
                if line.find(pkg) != -1:
                    found = True
                    break
            if not found:
                return False
        return True

    def pkg_version(self, package_name):
        std_out = getoutput("rpm", "-qa",
                            "--qf", "'%{VERSION}-%{RELEASE}\n'",
                            package_name)
        # Need to capture the version string
        # check the command output
        for line in std_out.split("\n"):
            regex = re.compile("[0-9.]+-.*")
            matches = regex.match(line)
            if matches:
                line = matches.group()
                return line

        LOG.error("Unexpected output from rpm command. (%(output)s)",
                  {'output': std_out})

    def pkg_remove(self, package_name, time_out):
        """Removes a package."""
        if self.pkg_version(package_name) is None:
            return
        result = self._remove(package_name, time_out)
        if result != OK:
            raise PkgPackageStateError(_("Package %s is in a bad state.")
                                       % package_name)


class RedhatPackagerMixin(RPMPackagerMixin):
    def _install(self, packages, time_out):
        """Attempts to install packages.

        Returns OK if the packages are installed or a result code if a
        recoverable-error occurred.
        Raises an exception if a non-recoverable error or timeout occurs.

        """
        cmd = "sudo yum --color=never -y install %s" % " ".join(packages)
        output_expects = [r'\[sudo\] password for .*:',
                          'No package (.*) available.',
                          ('file .* from install of .* conflicts with file'
                           ' from package (.*?)\r\n'),
                          'Error: (.*?) conflicts with .*?\r\n',
                          'Processing Conflict: .* conflicts (.*?)\r\n',
                          '.*scriptlet failed*',
                          'HTTP Error',
                          'No more mirrors to try.',
                          'GPG key retrieval failed:',
                          '.*already installed and latest version',
                          'Updated:',
                          'Installed:']
        LOG.debug("Running package install command: %s", cmd)
        i, match = self.pexpect_run(cmd, output_expects, time_out)
        if i == 0:
            raise PkgPermissionError(_("Invalid permissions."))
        elif i == 1:
            raise PkgNotFoundError(_("Could not find package %s") %
                                   match.group(1))
        elif i == 2 or i == 3 or i == 4:
            self._rpm_remove_nodeps(match.group(1))
            return CONFLICT_REMOVED
        elif i == 5:
            raise PkgScriptletError(_("Package scriptlet failed"))
        elif i == 6 or i == 7:
            raise PkgDownloadError(_("Package download problem"))
        elif i == 8:
            raise PkgSignError(_("GPG key retrieval failed"))
        return OK

    def _remove(self, package_name, time_out):
        """Removes a package.

        Returns OK if the package is removed successfully or a result code if a
        recoverable-error occurs.
        Raises an exception if a non-recoverable error or timeout occurs.

        """
        cmd = "sudo yum --color=never -y remove %s" % package_name
        LOG.debug("Running package remove command: %s", cmd)
        output_expects = [r'\[sudo\] password for .*:',
                          'No Packages marked for removal',
                          'Removed:']
        i, match = self.pexpect_run(cmd, output_expects, time_out)
        if i == 0:
            raise PkgPermissionError(_("Invalid permissions."))
        elif i == 1:
            raise PkgNotFoundError(_("Could not find package %s") %
                                   package_name)
        return OK


class DebianPackagerMixin(BasePackagerMixin):

    def _fix(self, time_out):
        """Sometimes you have to run this command before a
            package will install.
        """
        try:
            utils.execute("dpkg", "--configure", "-a", run_as_root=True,
                          root_helper="sudo")
        except ProcessExecutionError:
            LOG.exception("Error fixing dpkg")

    def _fix_package_selections(self, packages, config_opts):
        """
        Sometimes you have to run this command before a package will install.
        This command sets package selections to configure package.
        """
        selections = ""
        for package in packages:
            m = re.match('(.+)=(.+)', package)
            if m:
                package_name = m.group(1)
            else:
                package_name = package
            std_out = getoutput("sudo", "debconf-show", package_name)
            for line in std_out.split("\n"):
                for selection, value in config_opts.items():
                    m = re.match(".* (.*/%s):.*" % selection, line)
                    if m:
                        selections += ("%s %s string '%s'\n" %
                                       (package_name, m.group(1), value))
        if selections:
            with NamedTemporaryFile(delete=False) as f:
                fname = f.name
                f.write(encodeutils.safe_encode(selections))
            try:
                utils.execute("debconf-set-selections", fname,
                              run_as_root=True, root_helper="sudo")
                utils.execute("dpkg", "--configure", "-a",
                              run_as_root=True, root_helper="sudo")
            except ProcessExecutionError:
                raise PkgConfigureError(_("Error configuring package."))
            finally:
                os.remove(fname)

    def _install(self, packages, time_out):
        """Attempts to install packages.

        Returns OK if the packages are installed or a result code if a
        recoverable-error occurred.
        Raises an exception if a non-recoverable error or timeout occurs.

        """
        cmd = "sudo -E DEBIAN_FRONTEND=noninteractive apt-get -y " \
              "--force-yes --allow-unauthenticated -o " \
              "DPkg::options::=--force-confmiss --reinstall " \
              "install %s" % " ".join(packages)
        output_expects = ['.*password*',
                          'E: Unable to locate package (.*)',
                          "Couldn't find package (.*)",
                          "E: Version '.*' for '(.*)' was not found",
                          ("dpkg was interrupted, you must manually run "
                           "'sudo dpkg --configure -a'"),
                          "Unable to lock the administration directory",
                          ("E: Unable to correct problems, you have held "
                           "broken packages."),
                          "Setting up (.*)",
                          "is already the newest version"]
        LOG.debug("Running package install command: %s", cmd)
        i, match = self.pexpect_run(cmd, output_expects, time_out)
        if i == 0:
            raise PkgPermissionError(_("Invalid permissions."))
        elif i == 1 or i == 2 or i == 3:
            raise PkgNotFoundError(_("Could not find package %s") %
                                   match.group(1))
        elif i == 4:
            return RUN_DPKG_FIRST
        elif i == 5:
            raise PkgAdminLockError()
        elif i == 6:
            raise PkgBrokenError()
        return OK

    def _remove(self, package_name, time_out):
        """Removes a package.

        Returns OK if the package is removed successfully or a result code if a
        recoverable-error occurs.
        Raises an exception if a non-recoverable error or timeout occurs.

        """
        cmd = "sudo -E apt-get -y --allow-unauthenticated remove %s" \
              % package_name
        output_expects = ['.*password*',
                          'E: Unable to locate package %s' % package_name,
                          'Package is in a very bad inconsistent state',
                          'Sub-process /usr/bin/dpkg returned an error code',
                          ("dpkg was interrupted, you must manually run "
                           "'sudo dpkg --configure -a'"),
                          "Unable to lock the administration directory",
                          "Removing %s*" % package_name]
        LOG.debug("Running remove package command %s", cmd)
        i, match = self.pexpect_run(cmd, output_expects, time_out)
        if i == 0:
            raise PkgPermissionError(_("Invalid permissions."))
        elif i == 1:
            raise PkgNotFoundError(_("Could not find package %s") %
                                   package_name)
        elif i == 2 or i == 3:
            return REINSTALL_FIRST
        elif i == 4:
            return RUN_DPKG_FIRST
        elif i == 5:
            raise PkgAdminLockError()
        return OK

    def pkg_install(self, packages, config_opts, time_out):
        """Installs packages."""
        try:
            utils.execute("apt-get", "update", run_as_root=True,
                          root_helper="sudo")
        except ProcessExecutionError:
            LOG.exception("Error updating the apt sources")

        result = self._install(packages, time_out)
        if result != OK:
            if result == RUN_DPKG_FIRST:
                self._fix(time_out)
            result = self._install(packages, time_out)
            if result != OK:
                raise PkgPackageStateError(_("Packages are in a bad state."))
        # even after successful install, packages can stay unconfigured
        # config_opts - is dict with name/value for questions asked by
        # interactive configure script
        if config_opts:
            self._fix_package_selections(packages, config_opts)

    def pkg_version(self, package_name):
        std_out = getoutput("apt-cache", "policy", package_name)
        for line in std_out.split("\n"):
            m = re.match(r"\s+Installed: (.*)", line)
            if m:
                version = m.group(1)
                if version == "(none)":
                    version = None
                return version

    def pkg_is_installed(self, packages):
        packages = packages if isinstance(packages, list) else packages.split()
        for pkg in packages:
            m = re.match('(.+)=(.+)', pkg)
            if m:
                package_name = m.group(1)
                package_version = m.group(2)
            else:
                package_name = pkg
                package_version = None
            installed_version = self.pkg_version(package_name)
            if ((package_version and installed_version == package_version) or
               (installed_version and not package_version)):
                LOG.debug("Package %s already installed.", package_name)
            else:
                return False
        return True

    def pkg_remove(self, package_name, time_out):
        """Removes a package."""
        if self.pkg_version(package_name) is None:
            return
        result = self._remove(package_name, time_out)

        if result != OK:
            if result == REINSTALL_FIRST:
                self._install(package_name, time_out)
            elif result == RUN_DPKG_FIRST:
                self._fix(time_out)
            result = self._remove(package_name, time_out)
            if result != OK:
                raise PkgPackageStateError(_("Package %s is in a bad state.")
                                           % package_name)


if operating_system.get_os() == operating_system.REDHAT:
    class Package(RedhatPackagerMixin):
        pass
else:
    class Package(DebianPackagerMixin):
        pass
