#! /usr/bin/python -u
# -*- coding: utf-8 -*-
# WARNING: python -u means unbuffered I/O without it the messages are
# passed to the parent asynchronously which looks bad in clients.

PROGNAME = "abrt-action-install-debuginfo"

import sys
import os
import errno
import time
import getopt
import shutil
import reportclient
from subprocess import Popen, PIPE
from yum import _, YumBase
from yum.callbacks import DownloadBaseCallback

# everything was ok
RETURN_OK = 0
# serious problem, should be logged somewhere
RETURN_FAILURE = 2


GETTEXT_PROGNAME = "abrt"
import locale
import gettext

_ = lambda x: gettext.lgettext(x)

def init_gettext():
    try:
        locale.setlocale(locale.LC_ALL, "")
    except locale.Error:
        os.environ['LC_ALL'] = 'C'
        locale.setlocale(locale.LC_ALL, "")
    gettext.bind_textdomain_codeset(GETTEXT_PROGNAME, locale.nl_langinfo(locale.CODESET))
    gettext.bindtextdomain(GETTEXT_PROGNAME, '/usr/share/locale')
    gettext.textdomain(GETTEXT_PROGNAME)


verbose = 0
def log(fmt, *args):
    sys.stderr.write("%s\n" % (fmt % args))

def log1(fmt, *args):
    """ prints log message if verbosity >= 1 """
    if verbose >= 1:
        sys.stderr.write("%s\n" % (fmt % args))

def log2(fmt, *args):
    """ prints log message if verbosity >= 2 """
    if verbose >= 2:
        sys.stderr.write("%s\n" % (fmt % args))

def error_msg(fmt, *args):
    sys.stderr.write("%s\n" % (fmt % args))

def error_msg_and_die(fmt, *args):
    sys.stderr.write("%s\n" % (fmt % args))
    sys.exit(1)


old_stdout = -1
def mute_stdout():
    if verbose < 2:
        global old_stdout
        old_stdout = sys.stdout
        sys.stdout = open("/dev/null", "w")

def unmute_stdout():
    if verbose < 2:
        if old_stdout != -1:
            sys.stdout = old_stdout
        else:
            print "ERR: unmute called without mute?"

# TODO: unpack just required debuginfo and not entire rpm?
# ..that can lead to: foo.c No such file and directory
# files is not used...
def unpack_rpm(package_file_name, files, tmp_dir, destdir, keeprpm):
    package_full_path = tmp_dir + "/" + package_file_name
    log1("Extracting %s to %s", package_full_path, destdir)
    log2("%s", files)
    print _("Extracting cpio from %s") % (package_full_path)
    unpacked_cpio_path = tmp_dir + "/unpacked.cpio"
    try:
        unpacked_cpio = open(unpacked_cpio_path, 'wb')
    except IOError, ex:
        print _("Can't write to '%s': %s") % (unpacked_cpio_path, ex)
        return RETURN_FAILURE
    rpm2cpio = Popen(["rpm2cpio", package_full_path],
                       stdout = unpacked_cpio, bufsize = -1)
    retcode = rpm2cpio.wait()

    if retcode == 0:
        log1("cpio written OK")
        if not keeprpm:
            log1("keeprpms = False, removing %s", package_full_path)
            #print _("Removing temporary rpm file")
            os.unlink(package_full_path)
    else:
        unpacked_cpio.close()
        print _("Can't extract package '%s'") % package_full_path
        return RETURN_FAILURE

    # close the file
    unpacked_cpio.close()
    # and open it for reading
    unpacked_cpio = open(unpacked_cpio_path, 'rb')

    print _("Caching files from %s made from %s") % ("unpacked.cpio", package_file_name)
    cpio = Popen(["cpio", "-idu", "--quiet"],
                  stdin=unpacked_cpio, cwd=destdir, bufsize=-1)
    retcode = cpio.wait()

    if retcode == 0:
        log1("files extracted OK")
        #print _("Removing temporary cpio file")
        os.unlink(unpacked_cpio_path)
    else:
        print _("Can't extract files from '%s'") % unpacked_cpio_path
        return RETURN_FAILURE

class MyDownloadCallback(DownloadBaseCallback):
    def __init__(self, total_pkgs):
        self.total_pkgs = total_pkgs
        self.downloaded_pkgs = 0
        self.last_pct = 0
        self.last_time = 0
        DownloadBaseCallback.__init__(self)

    def updateProgress(self, name, frac, fread, ftime):
        pct = int(frac * 100)
        if pct == self.last_pct:
            log2("percentage is the same, not updating progress")
            return

        self.last_pct = pct
        # if run from terminal we can have fancy output
        if sys.stdout.isatty():
            sys.stdout.write("\033[sDownloading (%i of %i) %s: %3u%%\033[u"
                    % (self.downloaded_pkgs + 1, self.total_pkgs, name, pct)
            )
            if pct == 100:
                print (_("Downloading (%i of %i) %s: %3u%%")
                        % (self.downloaded_pkgs + 1, self.total_pkgs, name, pct)
                )
        # but we want machine friendly output when spawned from abrt-server
        else:
            t = time.time()
            if self.last_time == 0:
                self.last_time = t
            # update only every 5 seconds
            if pct == 100 or self.last_time > t or t - self.last_time >= 5:
                print (_("Downloading (%i of %i) %s: %3u%%")
                        % (self.downloaded_pkgs + 1, self.total_pkgs, name, pct)
                )
                self.last_time = t
                if pct == 100:
                    self.last_time = 0

        sys.stdout.flush()

class DebugInfoDownload(YumBase):
    def __init__(self, cache, tmp, keep_rpms=False):
        self.cachedir = cache
        self.tmpdir = tmp
        self.keeprpms = keep_rpms
        YumBase.__init__(self)
        mute_stdout()
        #self.conf.cache = os.geteuid() != 0
        # Setup yum (Ts, RPM db, Repo & Sack)
        # doConfigSetup() takes some time, let user know what we are doing
        print _("Initializing yum")
        try:
            # Saw this exception here:
            # cannot open Packages index using db3 - Permission denied (13)
            # yum.Errors.YumBaseError: Error: rpmdb open failed
            self.doConfigSetup()
        except Exception, e:
            unmute_stdout()
            print _("Error initializing yum (YumBase.doConfigSetup): '%s'") % str(e)
            #return 1 - can't do this in constructor
            exit(1)
        unmute_stdout()

    # return value will be used as exitcode. So 0 = ok, !0 - error
    def download(self, files):
        """ @files - """
        installed_size = 0
        total_pkgs = 0
        todownload_size = 0
        downloaded_pkgs = 0
        # nothing to download?
        if not files:
            return

        #if verbose == 0:
        #    # this suppress yum messages about setting up repositories
        #    mute_stdout()

        # make yumdownloader work as non root user
        if not self.setCacheDir():
            self.logger.error("Error: can't make cachedir, exiting")
            exit(50)

        # disable all not needed
        for repo in self.repos.listEnabled():
            repo.close()
            self.repos.disableRepo(repo.id)

        # This takes some time, let user know what we are doing
        print _("Setting up yum repositories")
        # setting-up repos one-by-one, so we can skip the broken ones...
        # this helps when users are using 3rd party repos like rpmfusion
        # in rawhide it results in: Can't find valid base url...
        for r in self.repos.findRepos(pattern="*debuginfo*"):
            try:
                rid = self.repos.enableRepo(r.id)
                self.repos.doSetup(thisrepo=str(r.id))
                log1("enabled repo %s", rid)
                setattr(r, "skip_if_unavailable", True)
            except Exception, ex:
                print _("Can't setup %s: %s, disabling") % (r.id, ex)
                self.repos.disableRepo(r.id)

        # This is somewhat "magic", it unpacks the metadata making it usable.
        # Looks like this is the moment when yum talks to remote servers,
        # which takes time (sometimes minutes), let user know why
        # we have "paused":
        print _("Looking for needed packages in repositories")
        try:
            self.repos.populateSack(mdtype='metadata', cacheonly=1)
        except Exception, e:
            print _("Error retrieving metadata: '%s'") % str(e)
            return 1
        try:
            # Saw this exception here:
            # raise Errors.NoMoreMirrorsRepoError, errstr
            # NoMoreMirrorsRepoError: failure:
            # repodata/7e6632b82c91a2e88a66ad848e231f14c48259cbf3a1c3e992a77b1fc0e9d2f6-filelists.sqlite.bz2
            # from fedora-debuginfo: [Errno 256] No more mirrors to try.
            self.repos.populateSack(mdtype='filelists', cacheonly=1)
        except Exception, e:
            print _("Error retrieving filelists: '%s'") % str(e)
            return 1

        #if verbose == 0:
        #    # re-enable the output to stdout
        #    unmute_stdout()

        not_found = []
        package_files_dict = {}
        for debuginfo_path in files:
            log2("yum whatprovides %s", debuginfo_path)
            pkg = self.pkgSack.searchFiles(debuginfo_path)
            # sometimes one file is provided by more rpms, we can use either of
            # them, so let's use the first match
            if pkg:
                if pkg[0] in package_files_dict.keys():
                    package_files_dict[pkg[0]].append(debuginfo_path)
                else:
                    package_files_dict[pkg[0]] = [debuginfo_path]
                    todownload_size += float(pkg[0].size)
                    installed_size += float(pkg[0].installedsize)
                    total_pkgs += 1

                log2("found pkg for %s: %s", debuginfo_path, pkg[0])
            else:
                log2("not found pkg for %s", debuginfo_path)
                not_found.append(debuginfo_path)

        # connect our progress update callback
        dnlcb = MyDownloadCallback(total_pkgs)
        self.repos.setProgressBar(dnlcb)

	if verbose != 0 or len(not_found) != 0:
	    print _("Can't find packages for %u debuginfo files") % len(not_found)
	if verbose != 0 or total_pkgs != 0:
	    print _("Packages to download: %u") % total_pkgs
	    question = _("Downloading %.2fMb, installed size: %.2fMb. Continue?") % (
	                 todownload_size / (1024**2),
	                 installed_size / (1024**2)
	                )
	    if noninteractive == False and not reportclient.ask_yes_no(question):
	        print _("Download cancelled by user")
	        return RETURN_OK

        for pkg, files in package_files_dict.iteritems():
            dnlcb.downloaded_pkgs = downloaded_pkgs
            repo.cache = 0
            remote = pkg.returnSimple('relativepath')
            local = os.path.basename(remote)
            if not os.path.exists(self.tmpdir):
                os.makedirs(self.tmpdir)
            if not os.path.exists(self.cachedir):
                os.makedirs(self.cachedir)
            local = os.path.join(self.tmpdir, local)
            pkg.localpath = local # Hack: to set the localpath we want
            err = self.downloadPkgs(pkglist=[pkg])
            # normalize the name
            # just str(pkg) doesn't work because it can have epoch
            pkg_nvra = pkg.name + "-" + pkg.version + "-" + pkg.release + "." + pkg.arch
            package_file_name = pkg_nvra + ".rpm"
            if err:
                # I observed a zero-length file left on error,
                # which prevents cleanup later. Fix it:
                try:
                    os.unlink(self.tmpdir + "/" + package_file_name)
                except:
                    pass
                print (_("Downloading package %s failed") % pkg)
            else:
                unpack_result = unpack_rpm(package_file_name, files, self.tmpdir,
                                           self.cachedir, keeprpms)
                if unpack_result == RETURN_FAILURE:
                    # recursively delete the temp dir on failure
                    print _("Unpacking failed, aborting download...")
                    clean_up()
                    return RETURN_FAILURE

            downloaded_pkgs += 1

        if not self.keeprpms and os.path.exists(self.tmpdir):
            # Was: "All downloaded packages have been extracted, removing..."
            # but it was appearing even if no packages were in fact extracted
            # (say, when there was one package, and it has download error).
            print (_("Removing %s") % self.tmpdir)
            try:
                os.rmdir(self.tmpdir)
            except OSError:
                error_msg(_("Can't remove %s, probably contains an error log"), self.tmpdir)

def build_ids_to_path(build_ids):
    """
    build_id1=${build_id:0:2}
    build_id2=${build_id:2}
    file="usr/lib/debug/.build-id/$build_id1/$build_id2.debug"
    """
    return ["/usr/lib/debug/.build-id/%s/%s.debug" % (b_id[:2], b_id[2:]) for b_id in build_ids]

# beware this finds only missing libraries, but not the executable itself ..

def filter_installed_debuginfos(build_ids, cache_dirs):
    files = build_ids_to_path(build_ids)
    missing = []

    # 1st pass -> search in /usr/lib
    for debuginfo_path in files:
        log2("looking: %s", debuginfo_path)
        if os.path.exists(debuginfo_path):
            log2("found: %s", debuginfo_path)
            continue
        log2("not found: %s", debuginfo_path)
        missing.append(debuginfo_path)

    if missing:
        files = missing
        missing = []
    else: # nothing is missing, we can stop looking
        return missing

    for cache_dir in cache_dirs:
        log2("looking in %s" % cache_dir)
        for debuginfo_path in files:
            cache_debuginfo_path = cache_dir + debuginfo_path
            log2("looking: %s", cache_debuginfo_path)
            if os.path.exists(cache_debuginfo_path):
                log2("found: %s", cache_debuginfo_path)
                continue
            log2("not found: %s", debuginfo_path)
            missing.append(debuginfo_path)
        # in next iteration look only for files missing
        # from previous iterations
        if missing:
            files = missing
            missing = []
        else: # nothing is missing, we can stop looking
            return missing

    return files

tmpdir = None
def clean_up():
    if tmpdir:
        try:
            shutil.rmtree(tmpdir)
        except OSError, ex:
            if ex.errno != errno.ENOENT:
                error_msg(_("Can't remove '%s': %s"), tmpdir, ex)

def sigterm_handler(signum, frame):
    clean_up()
    exit(RETURN_OK)

def sigint_handler(signum, frame):
    clean_up()
    print "\n", _("Exiting on user command")
    sys.stdout.flush()
    # ??! without "sys.", I am getting segv!
    sys.exit(RETURN_OK)

import signal

if __name__ == "__main__":
    # abrt-server can send SIGTERM to abort the download
    signal.signal(signal.SIGTERM, sigterm_handler)
    # ctrl-c
    signal.signal(signal.SIGINT, sigint_handler)
    fbuild_ids = "build_ids"
    cachedirs = []
    size_mb = 4096
    tmpdir = None
    keeprpms = False
    noninteractive = False
    b_ids = []

    # localization
    init_gettext()

    ABRT_VERBOSE = os.getenv("ABRT_VERBOSE")
    if (ABRT_VERBOSE):
        try:
            verbose = int(ABRT_VERBOSE)
        except:
            pass

    PROGNAME = os.path.basename(sys.argv[0])
    # ____________________________________________________________________________________ 7
    # ______01234567890123456789012345678901234567890123456789012345678901234567890123456789
    help_text = _(
            "Usage: %s [-vy] [--ids=BUILD_IDS_FILE]\n"
            "       [--tmpdir=TMPDIR] [--cache=CACHEDIR[:DEBUGINFODIR1:DEBUGINFODIR2...]] [--size_mb=SIZE]\n"
            "\n"
            "Installs debuginfos for all build-ids listed in BUILD_IDS_FILE\n"
            "to CACHEDIR, using TMPDIR as temporary staging area.\n"
            "Old files in CACHEDIR are deleted until it is smaller than SIZE.\n"
            "\n"
            "    -v          Be verbose\n"
            "    -y          Noninteractive, assume 'Yes' to all questions\n"
            "    --ids       Default: build_ids\n"
            "    --tmpdir    Default: /tmp/abrt-tmp-debuginfo-RANDOM_SUFFIX\n"
            "    --cache     Default: /var/cache/abrt-di\n"
            "    --size_mb   Default: 4096\n"
            # --keeprpms is not documented yet because it's a NOP so far
    ) % PROGNAME

    try:
        opts, args = getopt.getopt(sys.argv[1:], "vyh",
                ["help", "ids=", "cache=", "size_mb=", "tmpdir=", "keeprpms"])
    except getopt.GetoptError, err:
        print str(err) # prints something like "option -a not recognized"
        exit(RETURN_FAILURE)

    for opt, arg in opts:
        if opt in ("-h", "--help"):
            print help_text
            exit(0)
        elif opt == "-v":
            verbose += 1
        elif opt == "-y":
            noninteractive = True
        elif opt == "--ids":
            fbuild_ids = arg
        elif opt == "--cache":
            cachedirs = arg.split(':')
        elif opt == "--size_mb":
            try:
                size_mb = int(arg)
            except:
                pass
        elif opt == "--tmpdir":
            tmpdir = arg
        elif opt == "--keeprpms":
            keeprpms = True

    if not cachedirs:
        cachedirs = ["/var/cache/abrt-di"]
    if not tmpdir:
        # security people prefer temp subdirs in app's private dir, like /var/run/abrt
        # for now, we use /tmp...
        tmpdir = "/tmp/abrt-tmp-debuginfo-%s.%u" % (time.strftime("%Y-%m-%d-%H:%M:%S"), os.getpid())

    fin = sys.stdin
    if fbuild_ids != "-":
        try:
            fin = open(fbuild_ids, "r")
        except IOError, ex:
            error_msg_and_die(_("Can't open %s: %s"), fbuild_ids, ex)
    for line in fin.readlines():
        b_ids.append(line.strip('\n'))

    if not b_ids:
        exit(RETURN_FAILURE)

    # Delete oldest/biggest files from cachedir.
    # (Note that we need to do it before we check for missing debuginfos)
    #
    # We can do it as a separate step in abrt_event.conf, but this
    # would require setuid'ing abrt-action-trim-files to abrt:abrt.
    # Since we (via abrt-action-install-debuginfo-to-abrt-cache)
    # are already running setuid,
    # it makes sense to NOT setuid abrt-action-trim-files too,
    # but instead run it as our child:
    sys.stdout.flush()
    try:
        pid = os.fork()
        if pid == 0:
            log1("abrt-action-trim-files -f %um:%s", size_mb, cachedirs[0]);
            os.execlp("abrt-action-trim-files", "abrt-action-trim-files", "-f", "%um:%s" % (size_mb, cachedirs[0]));
        if pid > 0:
            os.waitpid(pid, 0);
    except Exception, e:
        error_msg("Can't execute abrt-action-trim-files: %s", e);

    missing = filter_installed_debuginfos(b_ids, cachedirs)
    if missing:
        log2("%s", missing)
        print _("Coredump references %u debuginfo files, %u of them are not installed") % (len(b_ids), len(missing))

        # TODO: should we pass keep_rpms=keeprpms to DebugInfoDownload here??
        downloader = DebugInfoDownload(cache=cachedirs[0], tmp=tmpdir)
        try:
            result = downloader.download(missing)
        except Exception, ex:
            error_msg_and_die("Can't download debuginfos: %s", ex)

        missing = filter_installed_debuginfos(b_ids, cachedirs)
        for bid in missing:
            print _("Missing debuginfo file: %s") % bid

    if not missing:
        print _("All %u debuginfo files are available") % len(b_ids)

    exit(RETURN_OK)
