#! /usr/bin/python3 -sP
# vim: fileencoding=utf8
# Copyright 2010-2017 Till Maas and others
# This file is part of fedora-easy-karma.
#
# Fedora-easy-karma is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# Fedora-easy-karma is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with fedora-easy-karma.  If not, see <http://www.gnu.org/licenses/>.

# standard python modules
import argparse
import datetime
import fnmatch
import itertools
import io
import os
import pickle
import readline
import shlex
import sys
import termios
from textwrap import wrap

# third-party python modules
from bodhi.client.bindings import BodhiClient, BodhiClientException
import colored
import dnf
import munch
import requests

# Setting constants
PROMPT = "Comment? -1/0/1 -> karma, 'i' -> ignore, other -> skip> "
CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME") or "~/.config", 'fedora-easy-karma')
USAGE = """
You will be asked for every package installed from updates-testing to
provide feedback using karma points. If patterns are provided, you will be
only prompted for updates related to packages or builds that match any of
the patterns.  Possible wildcards are *, ?, [seq] and [!seq]. More is about
the wildcards is explained at <http://docs.python.org/library/fnmatch.html>.

Possible values in the karma prompt:
    -1,0 or 1: Assign the respective karma value to the update
     i: Ignore the update in the future
     Other inputs will skip the update.

Note:
    * <CTRL>-<D> exits the program when used on empty prompt.
    * <CTRL>-<X> + <backspace> deletes the default comment to enter a new one.

Default cmdline options can be specified in ~/.config/fedora-easy-karma/default_cmdline .
Use the same syntax as when using them on the command line. These configured
defaults can be overriden, if needed, on an actual command line.

For further documentation, please visit:
https://fedoraproject.org/wiki/Fedora_Easy_Karma

Copyright 2010-2025 Till Maas and others. Fedora-easy-karma is distributed under
the terms of the GNU General Public License. The source is available at
<https://pagure.io/fedora-easy-karma>.
"""

class FEK_helper(object):
    @staticmethod
    def bodhi_update_str(
            update,
            bodhi_base_url="https://bodhi.fedoraproject.org/",
            bugzilla_bug_url="https://bugzilla.redhat.com/",
            test_cases_url="https://fedoraproject.org/wiki/",
            width=0,
            bodhi_comments="important",
            colorize = True):

        # copy update to avoid side effects
        values = dict(update)
        format_string = (
            "%(header_line)s\n"
            "%(title)s\n"
            "%(header_line)s\n"
            "%(updateid)s"
            "    Release: %(release)s\n"
            "     Status: %(status)s\n"
            "       Type: %(type)s\n"
            "      Karma: %(karma_status)s\n"
            "%(request)s"
            "%(bugs)s"
            "%(test_cases)s"
            "%(notes)s"
            "  Submitter: %(submitter)s\n"
            "  Submitted: %(date_submitted)s\n"
            "%(comments)s"
            "%(overallkarma)s"
            "\n%(update_url)s")

        values["header_line"] = "=" * width
        values["title"] = "\n".join(
            wrap(color(update["title"], "bold", colorize).replace(",", ", "),
                 width=width,
                 initial_indent=" " * 5,
                 subsequent_indent=" " * 5)
        )

        if update["updateid"]:
            values["updateid"] = "  Update ID: %s\n" % update["updateid"]
        else:
            values["updateid"] = ""

        values["release"] = update["release"]["long_name"]

        values["type"] = ""
        if "critpath" in update and update["critpath"]:
            # I'm not sure if this is what the data member acutally means,
            # assuming for now
            if not update["date_approved"]:
                values["type"] = "unapproved "

            values["type"] += "critpath "

        values["type"] += update["type"]

        if update["request"]:
            values["request"] = "    Request: %s\n" % update["request"]
        else:
            values["request"] = ""

        if len(update["bugs"]):
            bugs = []
            for bug in update["bugs"]:
                bug_id = bug["bug_id"]
                if bugzilla_bug_url:
                    bug_id = "%s%d" % (bugzilla_bug_url, bug_id)
                bz_title = bug["title"]
                bugs.append("%s - %s" % (bug_id, bz_title))

            values["bugs"] = "%s\n" % FEK_helper.wrap_paragraphs_prefix(
                bugs, first_prefix="       Bugs: ", width=width,
                extra_newline=True)
        else:
            values["bugs"] = ""

        test_cases = []
        for case in update["test_cases"]:
            tc = case['name']
            tc = tc.replace(" ", "_").replace(":", "%3A")
            test_cases.append(test_cases_url + tc)
        if len(test_cases) > 0:
            values["test_cases"] = "%s\n" % FEK_helper.wrap_paragraphs_prefix(
                test_cases, first_prefix=" Test Cases: ", width=width,
                extra_newline=True
            )
        else:
            values["test_cases"] = ""

        if update["notes"]:
            values["notes"] = "%s\n" % FEK_helper.wrap_paragraphs_prefix(
                update["notes"].splitlines(), first_prefix="      Notes: ",
                width=width
            )
        else:
            values["notes"] = ""

        if len(update["comments"]):
            val = "   Comments: "
            comments = []
            # To hold the overall karma for later calculations.
            overall_karma = []
            for comment in update["comments"]:
                # copy comment to avoid side effects
                comment = dict(comment)

                indent = " " * 13
                comment["indent"] = indent

                text = comment['text']

                # When text is left as one chunk even if it is multiline,
                # the wrap function used later on eats all the whitespaces
                # and removes the linebreaks.
                # If prevented using the replace_whitespace=False built-in
                # settings, one cannot control the indentation any more.
                # For this reason, let us split the comment on new lines.
                text = text.splitlines()

                # the format of the user has changed, add a data member
                comment["username"] = comment["user"]["name"]

                if comment['username'] == 'bodhi' and bodhi_comments == "none":
                    continue

                # keep the karma value for later calculations, but do not
                # count anything, if it comes from the Bodhi itself
                if comment['username'] != "bodhi":
                    overall_karma.append(comment['karma'])

                # Colorize according to karma
                tints = {-1: 'red', 0: 'orange_1', 1: 'green'}
                tint = tints.get(comment['karma'], 'reset')

                if comment['username'] == 'bodhi':
                    # For Bodhi comments, we believe that they are of lesser
                    # importance and therefore we will make them grey if
                    # colors are switched on.
                    tint = "grey_50"
                    # To maintain proper indentation, colorize first and then
                    # deal with the indentation.
                    if bodhi_comments == "important":
                        text = FEK_helper.return_important_comment(text)
                    if text:
                        authoring = f"{comment['username']} - {comment['timestamp']}"
                        authoring = color(authoring, tint, colorize)
                        authoring = f"{comment['indent']}{authoring}"
                        comments.append(authoring)
                else:
                    # For Non-Bodhi users, the color scheme will look a little
                    # bit different: the username is bold, the karma string
                    # is colorized, and the comments has deeper indentation.
                    # Similar to above, let's do coloring magic first, then deal
                    # with the indentation.
                    username = color(f"{comment['username']}", "bold", colorize)
                    karma = color(f"karma {comment['karma']}", tint, colorize)
                    comments.append(f"{comment['indent']}{username} - {comment['timestamp']} ({karma})")
                    # Set color to default to keep the comments not colored
                    # when they are not from the Bodhi user.
                    tint = "reset"


                # If there is text (if bodhi-comments is none or important
                # the text might be empty. In that case we'd skip it.
                if text:
                    # Deal with each line of the comment separately to
                    # enable correct indentation.
                    for line in text:
                        # Skip empty lines to keep the output more compact.
                        if not line:
                            continue
                        wrapped = wrap(color(line.strip(), tint, colorize),
                                   initial_indent=(indent+"  "),
                                   subsequent_indent=indent+"  ", width=width)
                        comments.append("\n".join(wrapped))

            val += "\n".join(comments).lstrip() + "\n"
            values["comments"] = val
            values["overallkarma"] = karma_info(overall_karma, colorize)
        else:
            values["comments"] = ""
            values["overallkarma"] = "       :"

        if update["alias"]:
            url_path = update["alias"]
        else:
            url_path = update["title"]

        values["update_url"] = "%supdates/%s\n" % (bodhi_base_url, url_path)
        values["update_url"] = f"  {color(values['update_url'], 'underline', colorize)}"
        # stable_karma can come back as None, meaning that auto-requesting push
        # to stable has been disabled.
        # https://github.com/fedora-infra/bodhi/issues/274
        if values['stable_karma'] is None:
            values['karma_status'] = "%d" % (values["karma"])
        else:
            values["karma_status"] = "%d/%s" % (values["karma"],
                                                values["stable_karma"])

        values["submitter"] = values["user"]["name"]
        return format_string % values

    @staticmethod
    def wrap_paragraphs(paragraphs, width=67,
                        subsequent_indent=(" " * 11 + ": "),
                        second_column_indent=0):
        return ("\n%s" % subsequent_indent).join(
            "\n".join(
                wrap(
                    p,
                    width=width,
                    subsequent_indent=(
                        subsequent_indent + " " * second_column_indent)
                )
            )
            for p in paragraphs
        )

    @staticmethod
    def wrap_paragraphs_prefix(paragraphs, first_prefix, width=80,
                               extra_newline=False):
        if isinstance(paragraphs, str):
            paragraphs = paragraphs.split("\n")

        if first_prefix:
            subsequent_indent = " " * (len(first_prefix) - 2) + ": "
        else:
            subsequent_indent = ""

        output = []
        first = True
        wrapped = []

        # remove trailing empty paragraphs
        while paragraphs and paragraphs[-1] == "":
            paragraphs.pop()

        for p in paragraphs:
            if extra_newline and len(wrapped) > 1:
                output.append("")
            if first:
                p = first_prefix + p
                first = False

            wrapped = wrap(p, width=width, subsequent_indent=subsequent_indent)
            output.append("\n".join(wrapped))

        return ("\n%s" % subsequent_indent).join(output)

    @staticmethod
    def format_rpms_output(rpms, first_prefix, width=80, extra_newline=False):
        """This method formats the RPMS output. It takes a bunch of rpms tuples
        with name, summary, install_day and creates output to match the Comments
        section."""
        output = []
        head_prefix = " " * len(first_prefix) # The way the indentation looks for rpm name.
        info_prefix = head_prefix + "  "
        first = True
        for rpm in rpms:
            name, summary, install = rpm
            name = color(name, "bold")
            # On the very first line, we will show the first_prefix (inst. RPMS: )
            # otherwise another prefix will be added.
            if first == True:
                name = first_prefix + name
                first = False
            else:
                name = head_prefix + name
            # The summary will be wrapped according to the set width using the
            # info_prefix for a second level of indentation.
            text = summary + " " + install
            text = wrap(text, initial_indent=info_prefix, subsequent_indent=info_prefix, width=width)
            output.append(name)
            output.append("\n".join(text))
        return "\n".join(output)

    @staticmethod
    def return_important_comment(comment_text):
        """Takes a Bodhi comment and returns it, if it is important,
        or returns an empty string."""
        important = ["edited this update"]
        # Iterate over the important and test against the comment_text.
        for message in important:
            # Comment_text might have multiple lines, so iterate over
            # each of them and test if we have a match.
            for line in comment_text:
                if message in line:
                    return comment_text
        return ""

class PkgHelper(object):
    def __init__(self):
        self.my = dnf.Base()
        self.my.fill_sack()
        self.releasever = dnf.rpm.detect_releasever("/")
        # make pkg objects subscriptable, i.e. pkg["name"] work
        dnf.package.Package.__getitem__ = lambda self, key: \
            getattr(self, key)

    @property
    def installed_packages(self):
        return self.my.sack.query().installed()

class FedoraEasyKarma(object):
    def __init__(self):

        self.config_dir = os.path.expanduser(CONFIG_DIR)
        self.config_filename = os.path.join(self.config_dir, 'default_cmdline')

        # handle config files
        self.create_config_files()
        config_args = self.read_config()

        # handle cmdline
        parser = self.create_argparser()
        self.options = self.parse_args(parser, config_args)
        self.setup_options()

        # Set the width for wrapping text. The self.autowidth
        # holds the information about this feature.
        self.autowidth = False
        self.termwidth = self.options.width
        if self.termwidth <= 0:
            self.termwidth = self.determine_termwidth()
            self.autowidth = True

        # log into Bodhi
        self.bc = BodhiClient()
        print("Logging into Bodhi...")
        try:
            self.bc.ensure_auth()
        except (requests.exceptions.RequestException) as e:
            print(f'There was a network error: {type(e)}: {e}')
            print('Please try again later. Exiting...')
            sys.exit(1)
        except BaseException as e:
            print(f'There was a error: {type(e)}: {e}')
            print('If you think this is a bug in fedora-easy-karma, please report it. Exiting...')
            sys.exit(1)

        # Pasting the login token into terminal can contain some extra newlines at the end, which
        # are queued on stdin and then interact with our input prompts. Drop everything currently
        # on stdin.
        # Note: If the application does not run in a terminal, the following code would crash,
        # therefore we need to use the exception here.
        try:
            termios.tcflush(sys.stdin, termios.TCIFLUSH)
        except io.UnsupportedOperation:
            self.warning("We are not on a terminal, terminal flushing was skipped.")

        pkghelper = PkgHelper()

        if not self.options.releasever:
            self.options.releasever = pkghelper.releasever
        release = "%s%s" % (self.options.product, self.options.releasever)

        installed_testing_builds = {}
        now = datetime.datetime.now()
        installed_max_days = datetime.timedelta(
            self.options.installed_max_days)
        installed_min_days = datetime.timedelta(
            self.options.installed_min_days)

        self.info("Getting list of installed packages...")
        self.debug("starting dnf query")
        for pkg in pkghelper.installed_packages:
            installed = datetime.datetime.fromtimestamp(pkg.installtime)
            installed_timedelta = now - installed
            if installed_timedelta < installed_max_days and \
                    installed_timedelta > installed_min_days:
                build = pkg.sourcerpm[:-8]
                if build in installed_testing_builds:
                    installed_testing_builds[build].append(pkg)
                else:
                    installed_testing_builds[build] = [pkg]

        cachefile_name = os.path.join(
            self.config_dir,
            "bodhi-cache-%s.cpickle" % release)
        if self.options.bodhi_cached:
            self.debug("reading bodhi cache")
            try:
                cachefile = open(cachefile_name, "rb")
                testing_updates = pickle.load(cachefile)
                cachefile.close()
            except IOError as ioe:
                print("Cannot access bodhi cache file: %s" % cachefile_name)
                sys.exit(ioe.errno)
        else:
            testing_updates = []
            if not self.options.oraculum_disabled:
                self.info("Waiting for oraculum instance to return list of packages in updates-testing...")
                testing_updates = self.query_oraculum(release)
            if not testing_updates:
                self.debug("starting bodhi query")
                self.info("Waiting for Bodhi for a list of packages in "
                        "updates-testing (%s)..." % release)
                testing_updates = self.query_bodhi(release, pending=False)

                # can't query for requestless as of python-fedora 0.3.18
                # (request=None results in no filtering by request)
                testing_updates = [x for x in testing_updates if not x["request"]]

                # extend list of updates with updates that are going to testing to
                # support manually installed rpms from koji
                pending_updates = self.query_bodhi(release, pending=True)
                testing_updates.extend(pending_updates)

            print("found {} testing updates".format(len(testing_updates)))

            if self.options.bodhi_update_cache:
                try:
                    os.makedirs(self.config_dir)
                except OSError:
                    # only pass for Errno 17: file exists
                    self.debug("makedirs OSError")
                self.debug("writing cache")
                outfile = open(cachefile_name, "wb")
                pickle.dump(testing_updates, outfile, -1)
                outfile.close()

        ignorefile_name = os.path.join(self.config_dir, "ignore.cpickle")
        previously_ignored_updates = []

        # Reading the ignore file to load the previously ignored update.
        self.debug("reading ignore file %s" % ignorefile_name)
        if os.path.isfile(ignorefile_name):
            try:
                with open(ignorefile_name, "rb") as ignorefile:
                    previously_ignored_updates = pickle.load(ignorefile)
            except BaseException as e:
                self.warning(f"Can't load ignored list {ignorefile_name}: {e}")

        self.debug("post processing bodhi query")
        # reduce to unapproved critpath updates. we could possibly query for
        # this directtly but might not want to do to keep the cache complete
        if self.options.critpath_only:
            testing_updates = [u for u in testing_updates if u["critpath"] and
                               not u["critpath_approved"]]
        # create a mapping build -> update
        testing_builds = {}
        for update in testing_updates:
            if self.options.include_commented or not \
                    self.already_commented(update, self.bc.username):
                for build in update["builds"]:
                    testing_builds[build["nvr"]] = update

        self.debug("starting feedback loop")
        # multiple build can be grouped together in one update, only ask once
        # per update
        processed_updates = []
        ignored_updates = []
        builds = sorted(testing_builds)

        if not builds:
            print("No testing packages found, install some with: "\
                "'dnf update --enablerepo=\"*-testing\"'")
        for build in builds:
            update = testing_builds[build]

            # Do not query for previously ignored updates
            # Store update title to save these to a file
            if not self.options.include_ignored and \
                    update.title in previously_ignored_updates:
                print("ignored: %s" % update.title)
                ignored_updates.append(update.title)
                continue

            # Ignore own updates
            if self.options.ignore_own and \
                    update["user"]["name"] == self.bc.username:
                continue

            if update not in processed_updates and \
                    build in installed_testing_builds:
                processed_updates.append(update)

                affected_builds = [b["nvr"] for b in update["builds"]]
                installed_pkgs = list(
                    itertools.chain(*[installed_testing_builds[b] for
                                      b in affected_builds if
                                      b in installed_testing_builds])
                )

                if self.options.pattern:
                    installed_pkgs_names = ["%(name)s" % pkg for pkg in
                                            installed_pkgs]
                    # remove version and release
                    affected_builds_names = ["-".join(b.split("-")[:-2]) for b
                                             in affected_builds]
                    if not self.match_any(self.options.pattern, [installed_pkgs_names,
                                                 affected_builds_names]):
                        continue
                installed_rpms = [
                    self.format_rpm(pkg) for pkg in installed_pkgs]
                # If --pages was used and --list-rpms-only was not used,
                # clear the screen.
                if self.options.pages and not self.options.list_rpms_only:
                    os.system('clear')
                if self.options.ipdb:
                    import ipdb
                    ipdb.set_trace()
                if not self.options.list_rpms_only:
                    # To be sure, update the terminal width to fit nicely
                    # if the terminal has been resized in between.
                    if self.autowidth:
                        self.termwidth = self.determine_termwidth()
                    print(FEK_helper.bodhi_update_str(
                        update, bodhi_base_url=self.bc.base_url,
                        width=self.termwidth,
                        bodhi_comments=self.options.bodhi_comments,
                        colorize=not self.options.no_color
                    ))
                    fprefix = " inst. RPMS: "
                    print(FEK_helper.format_rpms_output(
                            installed_rpms, first_prefix=fprefix,
                            width=self.termwidth-len(fprefix)))
                    if self.already_commented(update,
                                              self.bc.username):
                        print("!!! You have already commented on this update !!!")
                    try:
                        karma = self.input(
                            PROMPT, default=self.options.default_karma,
                            add_to_history=False)
                        if karma in ["-1", "0", "1"]:
                            comment = self.input("Comment> ", default=self.options.default_comment)
                            while not comment and karma in ["-1", "0"]:
                                print("Empty comment is not allowed for neutral or negative karma!")
                                comment = self.input("Comment> ", default=self.options.default_comment)
                            result = self.send_comment(update, comment, karma)
                            if not result[0]:
                                self.warning("Comment not submitted: %s" % result[1])
                        elif karma == "i":
                            ignored_updates.append(update.title)
                            print("ignored as requested")
                            self.save_ignored_updates(ignorefile_name, ignored_updates)

                    except EOFError:
                        ignored_updates.extend(previously_ignored_updates)
                        sys.stdout.write("\nExiting on User request\n")
                        break
                else:
                    for package in installed_rpms:
                        name, info, install = package
                        if not self.options.no_color:
                            name = color(name, "yellow")
                            info = color(info, "grey_70")
                            install = color(install, "grey_85")
                        print(f"{name} => {package[1]} {install}")

        if self.options.pattern:
            self.info('The displayed updates were limited by these patterns: {}\n'
                'If you want to show all testing updates, remove these patterns.'.format(
                ' '.join(self.options.pattern)))

    def create_config_files(self) -> None:
        """Make sure the config dir is created and a config file template is present. Migrate the
        config dir from a previous location, if appropriate.
        In case of errors, print warnings, don't raise.
        """
        # migrate the config dir from the old location if appropriate
        old_config_path = os.path.expanduser('~/.fedora-easy-karma')
        if not os.path.isdir(self.config_dir) and os.path.isdir(old_config_path):
            try:
                os.rename(old_config_path, self.config_dir)
            except OSError as e:
                self.warning(f"Can't migrate {old_config_path} to {self.config_dir}: {e}")

        # create a config dir if it doesn't exist
        if not os.path.isdir(self.config_dir):
            try:
                self.debug(f'Creating config dir {self.config_dir}')
                os.makedirs(self.config_dir)
            except OSError as e:
                self.warning(f"Can't create config dir {self.config_dir}: {e}")

        # create an empty config file if not present
        if not os.path.exists(self.config_filename):
            try:
                with open(self.config_filename, 'w') as config_file:
                    config_file.write('''\
# Specify command-line options that you want to activate by default. You can use one or more lines.
# Options stated here can be later overriden on the command line itself. See --help for more info.
# E.g.: --pages --installed-min-days=2
'''
                                      )
            except IOError as e:
                self.warning(f"Can't create config file {self.config_filename}: {e}")

    def read_config(self) -> list[str]:
        """Read the config file and return a list of default cmdline options (or empty list). In
        case of an error, just print a warning and return empty list (don't raise).
        """
        config_args = []
        if not os.path.isfile(self.config_filename):
            return []
        try:
            with open(self.config_filename, 'r') as config_file:
                lines = config_file.readlines()
                for line in lines:
                    # this automatically strips whitespace, ignores #comments, and understands
                    # "args with spaces surrounded by single/double quotes"
                    config_args.extend(shlex.split(line, comments=True))
            return config_args
        except IOError as e:
            self.warning(f"Can't read config file {self.config_filename}: {e}")
            return []

    def create_argparser(self) -> argparse.ArgumentParser:
        """Create an ArgumentParser.
        """
        usage = FEK_helper.wrap_paragraphs_prefix(
            USAGE,
            first_prefix="",
            width=self.determine_termwidth(),
            extra_newline=False)

        parser = argparse.ArgumentParser(description=usage,
                                         formatter_class=argparse.RawDescriptionHelpFormatter,
                                         exit_on_error=False)
        parser.add_argument("--bodhi-cached", dest="bodhi_cached",
                            help="Use cached bodhi query",
                            action="store_true",
                            default=False)
        parser.add_argument("--bodhi-update-cache",
                            dest="bodhi_update_cache",
                            help="Update bodhi query cache",
                            action="store_true",
                            default=False)
        parser.add_argument("--critpath-only",
                            dest="critpath_only",
                            help="Only consider unapproved critpath updates",
                            action="store_true",
                            default=False)
        parser.add_argument("--debug",
                            dest="debug",
                            help="Enable debug output",
                            action="store_true",
                            default=False)
        parser.add_argument("--default-comment",
                            dest="default_comment",
                            help="Default comment to use (default: %(default)s)",
                            default="",
                            metavar="COMMENT")
        parser.add_argument("--default-karma",
                            dest="default_karma",
                            help="Default karma to use (default: %(default)s)",
                            default="",
                            metavar="KARMA")
        parser.add_argument("--no-color",
                            dest="no_color",
                            help="Do not colorize output",
                            action="store_true",
                            default=False)
        parser.add_argument("--no-ignore-own",
                            dest="ignore_own",
                            help="Do not ignore own updates.",
                            action="store_false",
                            default=True)
        parser.add_argument("--include-commented",
                            dest="include_commented",
                            help="Also ask for more comments on updates that "
                                 "already got a comment from you, this is "
                                 "enabled if patterns are provided",
                            action="store_true",
                            default=False)
        parser.add_argument("--include-ignored",
                            dest="include_ignored",
                            help="Also ask for comments on updates that have "
                                 "been ignored previously.",
                            action="store_true",
                            default=False)
        parser.add_argument("--installed-max-days",
                            dest="installed_max_days",
                            help="Only check packages installed within the last "
                                 "XX days (default: %(default)d)",
                            metavar="DAYS",
                            default=28,
                            type=int)
        parser.add_argument("--installed-min-days",
                            dest="installed_min_days",
                            help="Only check packages installed for at least "
                                 "XX days (default: %(default)d)",
                            metavar="DAYS",
                            default=0,
                            type=int)
        parser.add_argument("--ipdb",
                            dest="ipdb",
                            help="Launch ipbd for debugging",
                            action="store_true",
                            default=False)
        parser.add_argument("--list-rpms-only",
                            dest="list_rpms_only",
                            help="Only list affected rpms",
                            action="store_true",
                            default=False)
        parser.add_argument("--pages",
                            dest="pages",
                            help="Clear terminal before each new presented update",
                            action="store_true",
                            default=False)
        parser.add_argument("--product",
                            dest="product",
                            help="product to query Bodhi for, 'F' for Fedora, "
                                 "'EL-' for EPEL (default: %(default)s)",
                            default="F")
        parser.add_argument("--releasever",
                            dest="releasever",
                            help="releasever to query Bodhi for "
                                 "(default: releasever from dnf)",
                            default=None)
        parser.add_argument("--retries",
                            dest="retries",
                            help="Number if retries when submitting a comment "
                                 "in case of an error (default: %(default)d)",
                            default=3,
                            type=int)
        parser.add_argument("--bodhi-comments",
                            dest="bodhi_comments",
                            help="Decide which bodhi comments to show (default: %(default)s)",
                            choices=["all", "important", "none"],
                            default="important")
        parser.add_argument("--width",
                          dest="width",
                          help="width to use for line wrapping of updates "
                               "(default: %(default)s), use '0' to set "
                               "width based on the terminal width.",
                          default=0,
                          type=int)
        parser.add_argument("--bodhi-request-limit",
                            dest="bodhi_request_limit",
                            help="Maximum number of updates to request at "
                                 "once from Bodhi (default: %(default)d)",
                            default=25,
                            type=int)
        parser.add_argument("--oraculum-endpoint",
                            dest="oraculum_endpoint",
                            help="Specify URL for oraculum instance",
                            default="https://packager-dashboard.fedoraproject.org/api/v1/libkarma/")
        parser.add_argument("--no-cache",
                            dest="oraculum_disabled",
                            help="Bypass oraculum and force fetch from bodhi",
                            action="store_true",
                            default=False)
        parser.add_argument("pattern",
                            help="Show only updates matching one or more package name patterns",
                            nargs="*")
        return parser

    def parse_args(self, parser: argparse.ArgumentParser, config_args: list[str]) -> argparse.Namespace:
        """Parse cmdline args and return parsed options as an argparse.Namescape object. If parsing
        errors are found, print an error and exit.
        :param config_args: default args parsed from a config file. These are taken with a lower
        priority, and can later be overriden by actual cmdline args.
        """
        try:
            # config_args go first, so that they can be overriden by cmdline args (argparse handles it OK)
            # note: sys.argv[0] shouldn't be added as the first item, argparse doesn't expect that
            # when providing the args explicitly
            options = parser.parse_args(config_args + sys.argv[1:])
        except argparse.ArgumentError as e:
            parser.print_usage(file=sys.stderr)
            print(f'Error parsing command arguments: {e}', file=sys.stderr)
            if config_args:
                print(f'Please also check config file for correctness: {self.config_filename}',
                      file=sys.stderr)
            sys.exit(2)

        return options

    def setup_options(self) -> None:
        """Handle setting up various cmdline options after parsing was done.
        """
        if self.options.pattern:
            self.options.include_commented = True
            self.info('Showing only packages which match patterns: {}'.format(
                ' '.join(self.options.pattern)))

        if self.options.product == "F":
            release_filename = "/etc/fedora-release"
            try:
                with open(release_filename, "r") as release_file:
                    if "Rawhide" in release_file.read():
                        print("'Rawhide' found in %s, aborting, because " \
                              "there is no updates-testing for " \
                              "Rawhide" % release_filename)
                        sys.exit(1)
            except IOError:
                self.warning("Cannot read '%s', this system might not be "
                             "supported" % release_filename)

    def save_ignored_updates(self, ignore_filename, ignored_updates):
        """ Save ignored_updates onto a disk as ignore_filename. """
        self.debug("writing ignore file")
        try:
            with open(ignore_filename, "wb") as outfile:
                pickle.dump(ignored_updates, outfile, -1)
        except BaseException as e:
            self.warning(f"Ignored updates couldn't be saved to {ignore_filename}: {e}")

    def query_oraculum(self, release):
        try:
            updates = requests.get(self.options.oraculum_endpoint + release)
        except:
            return False
        if updates.status_code != 200:
            return False
        prep_updates = []
        for update in updates.json():
            prep_updates.append(munch.Munch(update))
        # Check if oraculum returned usable data
        if len(prep_updates) == 0:
            return False
        return prep_updates

    def query_bodhi(self, release, pending=False):
        """Deal with querying bodhi and combining all relevant pages into a
        single list of updates."""

        query_args = {"release": release,
                      "rows_per_page": self.options.bodhi_request_limit,
                     }
        if pending:
            query_args["request"] = "testing"
            query_args["status"] = "pending"
        else:
            query_args["status"] = "testing"

        updates = []
        try:
            # since bodhi has a query limit but multiple pages, get ALL of the
            # updates before starting to process
            result = self.bc.query(**query_args)
            self.debug("Queried Bodhi page 1")
            updates.extend(result['updates'])
            while result.page < result.pages:
                next_page = result['page'] + 1
                self.info("Fetching updates page {} of {}".format(
                    next_page, result['pages']))
                result = self.bc.query(page=next_page, **query_args)
                self.debug("Queried Bodhi page %s" % next_page)
                updates.extend(result['updates'])
        # There is no clear indication which Exceptions bc.query() might
        # throw, therefore catch all (python-fedora-0.3.32.3-1.fc19)
        except Exception as e:
            print("Error while querying Bodhi: {0}".format(e))
            raise e

        return updates

    def already_commented(self, update, user):
        for comment in update["comments"]:
            if comment["user"]["name"] == user:
                return True
        return False

    def debug(self, message):
        if hasattr(self, "options") and not self.options.debug:
            return

        message = f"DEBUG: {message}"
        sys.stderr.write("%s\n" % message)

    def format_rpm(self, rpm):
        now = datetime.datetime.now()
        install_age = (now - datetime.datetime.fromtimestamp(rpm.installtime))
        package = f"{rpm.name}-{rpm.version}-{rpm.release}.{rpm.arch}"
        res = (package, rpm.summary, f"(Installed {install_age.days} days ago)")
        return res

    def info(self, message):
        sys.stderr.write("%s\n" % message)

    def match_any(self, patterns, names):
        for name in list(itertools.chain(*names)):
            for pattern in patterns:
                if fnmatch.fnmatch(name, pattern):
                    return True
        return False

    def warning(self, message):
        sys.stderr.write("Warning: %s\n" % message)

    def input(self, prompt, default="", add_to_history=True):
        def pre_input_hook():
            readline.insert_text(default)
            readline.redisplay()

        readline.set_pre_input_hook(pre_input_hook)
        try:
            return input(prompt)
        finally:
            readline.set_pre_input_hook(None)
            if not add_to_history:
                try:
                    readline.remove_history_item(
                        readline.get_current_history_length() - 1)
                # raised when CTRL-D is used on first prompt
                except ValueError:
                    pass

    def refresh_csrf(self):
        '''Handles csrf refreshing'''
        self.bc.csrf_token = None
        self.bc.csrf()

    def send_comment(self, update, comment, karma):
        for retry in range(0, self.options.retries + 1):
            try:
                res = self.bc.comment(update["updateid"], comment, karma=karma)
                return (True, res)
            except BodhiClientException as e:
                self.warning("Bodhi Client error: %s" % str(e))
                if "csrf" in str(e).lower():
                    self.warning("Possible CSRF token mismatch, trying to obtain a new one...")
                    self.refresh_csrf()
        return (False, 'too many errors')

    def determine_termwidth(self):
        """Returns the actual width of the terminal."""
        # Try to get the terminal size and if that fails, we are not
        # on a terminal. In that case, set the width to 80.
        try:
            termsize = os.get_terminal_size()
            width = termsize[0]
        except OSError as e:
            self.debug(f"Terminal width could not be determined. See --help to set width manually.")
            width = 80
        return width


def karma_info(karma, colorize=True):
    """Takes a list of various karma values, counts single values and returns
    an info string that will be printed out when the info is requested."""
    neutral = karma.count(0)
    negative = karma.count(-1)
    # Create empty strings for both neutral and negative karma
    # and only fill them if any of them is bigger than 0, i.e.
    # there indeed is some karma.
    neline = ngline = connect = info = ""
    if neutral > 0:
        neline = color(f"{neutral} neutral", "orange_1", colorize)
    if negative > 0:
        ngline = color(f"{negative} negative", "red", colorize)
    if neutral > 0 and negative > 0:
        connect = " and "

    # We now return the note only if there is something.
    if neutral > 0 or negative > 0:
        info = f"\n  Note: This update has {neline}{connect}{ngline} karma. Consider a review.\n"
    return info

def color(text, colorname, colorize=True):
    """ Wrap text with ANSI codes to use colors on the CLI
    and return the alternated string including the codes.
    For color names consult the colored.library.Library.COLORS dict.
    The 'colorize' option allows you to disable colorization without using if-clauses. In that case,
    the input text is returned unchanged.
    """
    if not colorize:
        return text
    if colorname in ['bold', 'underline', 'reset']:
        ctag: str = f"{colored.style(colorname)}"
    else:
        ctag: str = f"{colored.fore(colorname)}"
    rtag: str = f"{colored.style('reset')}"
    ctext = f"{ctag}{text}{rtag}"
    return ctext

if __name__ == "__main__":
    try:
        fek = FedoraEasyKarma()
    except KeyboardInterrupt:
        print("aborted")
        sys.exit(0)
