#!/usr/bin/env python3
#
# SPDX-License-Identifier: GPL-3.0-only
#
# Copyright (C) 2024  Pavin Joseph <https://github.com/pavinjosdev>
#
# zypperoni is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3
# as published by the Free Software Foundation.
#
# zypperoni 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 zypperoni; if not, see <http://www.gnu.org/licenses/>.

import os
import sys
import time
import logging
import asyncio
import argparse
import tempfile
import subprocess
from uuid import uuid4
from shlex import quote
import xml.etree.ElementTree as ET

# Constants
ZYPPERONI_VERSION = "1.1.1"
ZYPPER_PID_FILE = "/run/zypp.pid"
ZYPPER_ENV = "ZYPP_CURL2=1 ZYPP_PCK_PRELOAD=1 ZYPP_SINGLE_RPMTRANS=1"

################################

# Function to get zypper version
def zypper_version():
    out, ret = shell_exec("zypper --version")
    version = out.split()[1].split(".")
    version = [int(x) for x in version] # MAJOR.MINOR.PATCH
    return version

# Function to get ANSI colored text
def color(text_type, text):
    # rgb color codes
    colors = {
        "input": (0, 170, 170), # cyan
        "success": (0, 170, 0), # green
        "info": (85, 85, 255), # bright blue
        "warning": (255, 255, 85), # bright yellow
        "error": (255, 85, 85), # bright red
        "exception": (170, 0, 170), # magenta
    }
    color = colors.get(text_type)
    # color only if running in terminal and color output is not disabled
    try:
        if color and sys.stdout.isatty() and args and not args.no_color:
            return f"\033[38;2;{color[0]};{color[1]};{color[2]}m{text} \033[38;2;255;255;255m"
    except NameError:
        pass
    return text

# Function to query user for yes or no
def query_yes_no(question, default=None):
    valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
    if default is None:
        prompt = " [y/n]: "
    elif default == "yes":
        prompt = " [Y/n]: "
    elif default == "no":
        prompt = " [y/N]: "
    else:
        raise ValueError(f"Invalid default answer: {default!r}")
    while True:
        sys.stdout.write(color("input", question + prompt))
        choice = input().lower()
        if default is not None and choice == "":
            return valid[default]
        elif choice in valid:
            return valid[choice]
        else:
            sys.stdout.write(color("warning", "Please respond with 'yes' or 'no' (or 'y' or 'n').\n"))

# Function to take exclusive control of future zypper invocations
def get_zypp_lock():
    our_pid = os.getpid()
    os.system(f"echo {our_pid} > {ZYPPER_PID_FILE}")
    return our_pid

# Function to release lock on zypper
def release_zypp_lock():
    if os.getuid() == 0:
        os.system(f"echo -n > {ZYPPER_PID_FILE}")
        return True
    else:
        return False

# Function to unmount temp dirs provided list of UUIDs
def unmount(UUID):
    try:
        ZYPPERONI_TMP_DIR
    except NameError:
        return False
    umount_counter = 0
    while umount_counter < len(UUID):
        umount_counter = 0
        for uuid in UUID:
            UMNT_OK = True
            if os.path.isdir(f"{ZYPPERONI_TMP_DIR}/{uuid}/rootfs"):
                dirs = umount_dirs.format(uuid=uuid)
                dirs = dirs.strip().split("\n")
                for dir in dirs:
                    findmnt_cmd = f"findmnt {dir} > /dev/null 2>&1"
                    umount_cmd = f"umount {dir} > /dev/null 2>&1"
                    if os.system(findmnt_cmd) == 0:
                        os.system(umount_cmd)
                        if os.system(findmnt_cmd) == 0:
                            UMNT_OK = False
            umount_counter += 1 if UMNT_OK else 0
        time.sleep(0.01)

# Function to recursively delete temp files
def recursive_delete(path):
    # perform some sanity checks
    try:
        ZYPPERONI_TMP_DIR
    except NameError:
        return False
    if not path.startswith("/tmp") or not path.startswith(ZYPPERONI_TMP_DIR):
        return False
    out, ret = shell_exec("mount -l | grep -c '/tmp/zypperoni_'")
    if int(out) > 0:
        return False
    command = f"rm -r {quote(path)} > /dev/null 2>&1"
    os.system(command)
    return True

# Function to cleanup on zypperoni exit
def zypperoni_cleanup():
    release_zypp_lock()
    try:
        recursive_delete(ZYPPERONI_TMP_DIR)
    except NameError:
        pass

# Function to get output and exit code of shell command
def shell_exec(command):
    res = subprocess.run(command, shell=True, capture_output=True, encoding="utf8", errors="replace")
    output = res.stdout + res.stderr
    return output.strip(), res.returncode

# Async function to perform zypper shell commands
async def zypper_task(lock, UUID, args, task_item, total_items, item_counter):
    try:
        async with lock:
            uuid = UUID.pop()
        log_messages = {}
        commands = ""
        temp_dir = f"{ZYPPERONI_TMP_DIR}/{uuid}/rootfs"
        if args.command_name in ["refresh", "ref"]:
            log_messages.update({"start": f"{'Force ' if args.force else ''}Refreshing repo [{item_counter}/{total_items}] {task_item!r}"})
            log_messages.update({"success": f"Successfully {'force ' if args.force else ''}refreshed repo {task_item!r}"})
            log_messages.update({"error": f"Error {'force ' if args.force else ''}refreshing repo [{item_counter}/{total_items}] {task_item!r}"})
            log_messages.update({"exception": f"Received SIGINT while {'force ' if args.force else ''}refreshing repo [{item_counter}/{total_items}] {task_item!r}"})
            if not os.path.isdir(temp_dir):
                commands = refresh_mount_commands + refresh_shell_commands
                commands = commands.format(
                    uuid=uuid,
                    refresh_type="refresh --force" if args.force else "refresh",
                    repo_alias=task_item,
                )
            else:
                commands = refresh_shell_commands.format(
                    uuid=uuid,
                    refresh_type="refresh --force" if args.force else "refresh",
                    repo_alias=task_item,
                )
        elif args.command_name in ["dist-upgrade", "dup", "install", "in", "install-new-recommends", "inr"]:
            log_messages.update({"start": f"Downloading package [{item_counter}/{total_items}] {task_item!r}"})
            log_messages.update({"success": f"Successfully downloaded package {task_item!r}"})
            log_messages.update({"error": f"Error downloading package [{item_counter}/{total_items}] {task_item!r}"})
            log_messages.update({"exception": f"Received SIGINT while downloading package [{item_counter}/{total_items}] {task_item!r}"})
            if not os.path.isdir(temp_dir):
                commands = download_mount_commands + download_shell_commands
                commands = commands.format(
                    uuid=uuid,
                    pkg_name=task_item,
                )
            else:
                commands = download_shell_commands.format(
                    uuid=uuid,
                    pkg_name=task_item,
                )
        logging.info(color("info", log_messages.get("start")))
        proc = await asyncio.create_subprocess_shell(
            commands,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        if proc.returncode == 0:
            logging.info(color("success", log_messages.get("success")))
        else:
            logging.error(color("error", f"{log_messages.get('error')}. zypper exit code: {proc.returncode}"))
        if stdout:
            logging.debug(f"[zypper output]\n{stdout.decode()}")
        if stderr:
            logging.debug(f"[zypper error]\n{stderr.decode()}")
        async with lock:
            UUID.append(uuid)
    except asyncio.exceptions.CancelledError:
        logging.debug(log_messages.get("exception"))

# Async function to perform multiple tasks concurrently
async def main_task(task_items, args):
    EXCEPTION_OCCUR = False
    # init array of temp dir UUIDs corresponding to max num of jobs
    UUID = [f"{uuid4()!s}" for _ in range(args.jobs)]
    UUID_UNCHANGED = UUID.copy()
    total_items = len(task_items)
    item_counter = 0
    if args.command_name in ["install", "in"]:
        install_pkgs = task_items.copy()
    try:
        # start processing tasks
        lock = asyncio.Lock()
        while task_items:
            if len(UUID) == 0:
                await asyncio.sleep(0.1)
                continue
            log_messages = {}
            task_item = task_items.pop(0)
            item_counter += 1
            if args.command_name in ["refresh", "ref"]:
                log_messages.update({"exception": f"Received SIGINT while processing tasks to {'force ' if args.force else ''}refresh repo"})
            elif args.command_name in ["dist-upgrade", "dup", "install", "in", "install-new-recommends", "inr"]:
                log_messages.update({"exception": "Received SIGINT while processing tasks to download packages"})
            asyncio.create_task(zypper_task(lock, UUID, args, task_item, total_items, item_counter))
            await asyncio.sleep(0.1)
        # finished processing all tasks
        tasks = asyncio.all_tasks()
        # wait for all tasks to finish
        while len(tasks) > 1:
            tasks = asyncio.all_tasks()
            await asyncio.sleep(0.1)
    except asyncio.exceptions.CancelledError:
        EXCEPTION_OCCUR = True
        logging.debug(log_messages.get("exception"))
        logging.info(color("info", "Cancelling pending tasks..."))
        for task in asyncio.all_tasks():
            task.cancel()
            await asyncio.sleep(0.1)
    finally:
        # cleanup temp mounts
        logging.info(color("info", "Cleaning up temp mounts..."))
        unmount(UUID_UNCHANGED.copy())
        # cleanup temp dir
        logging.info(color("info", "Cleaning up temp directory..."))
        recursive_delete(ZYPPERONI_TMP_DIR)
        # release zypper exclusive lock
        release_zypp_lock()
        # perform additional zypper commands (if any) on no exception
        if not EXCEPTION_OCCUR and \
        args.command_name in ["dist-upgrade", "dup", "install", "in", "install-new-recommends", "inr"] and \
        not args.download_only:
            msg = "Zypperoni has finished its tasks. Handing you over to zypper..."
            logging.info(color("info", msg))
            if args.command_name in ["dist-upgrade", "dup"]:
                command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd dist-upgrade"
                os.system(command)
            elif args.command_name in ["install", "in"]:
                command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd install {' '.join(install_pkgs)}"
                os.system(command)
            elif args.command_name in ["install-new-recommends", "inr"]:
                command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd install-new-recommends"
                os.system(command)

################################

# Init main parser and options
parser = argparse.ArgumentParser(
    formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    prog="zypperoni",
    description="zypperoni provides parallel operations for zypper's oft-used time consuming commands.",
    )
parser.add_argument("-v", "-V", "--version", action="store_true", help="print version number and exit")
parser.add_argument("-y", "--no-confirm", action="store_true", help="automatic yes to prompts, run non-interactively")
parser.add_argument("-j", "--jobs", type=int, default=10, choices=[x for x in range(5, 25, 5)], help="number of parallel operations")
parser.add_argument("--debug", action="store_true", help="enable debug output")
parser.add_argument("--no-color", action="store_true", help="disable color output")

# Init subparser for commands
subparsers = parser.add_subparsers(
    title="commands",
    description="type 'zypperoni <command> --help' to get command-specific help",
    dest="command_name",
    )

# Init parser for command: refresh / ref
help_text = "refresh all enabled repos"
parser_ref = subparsers.add_parser("refresh", aliases=["ref"], help=help_text, description=help_text)
parser_ref.add_argument("-f", "--force", action="store_true", help="force a complete refresh")

# Init parser for command: dist-upgrade / dup
help_text = "perform distribution upgrade"
parser_dup = subparsers.add_parser("dist-upgrade", aliases=["dup"], help=help_text, description=help_text)
parser_dup.add_argument("-d", "--download-only", action="store_true", help="download packages without installing")

# Init parser for command: install / in
help_text = "install one or more packages"
parser_in = subparsers.add_parser("install", aliases=["in"], help=help_text, description=help_text)
parser_in.add_argument("package", nargs="+", help="package name to install")
parser_in.add_argument("-d", "--download-only", action="store_true", help="download packages without installing")

# Init parser for command: install-new-recommends / inr
help_text = "install new packages recommended by already installed ones"
parser_inr = subparsers.add_parser("install-new-recommends", aliases=["inr"], help=help_text, description=help_text)
parser_inr.add_argument("-d", "--download-only", action="store_true", help="download packages without installing")

# Parse all options, commands, and arguments
args = parser.parse_args()

# Print help if there is nothing to be acted upon
if not args.command_name and not args.version:
    parser.print_help()
    sys.exit()

# Print version
if args.version:
    print(f"zypperoni v{ZYPPERONI_VERSION}")
    sys.exit()

# Setup logging
logging.basicConfig(
    stream=sys.stdout,
    format="%(asctime)s: %(levelname)s: %(message)s",
    level=logging.DEBUG if args.debug else logging.INFO,
)

# Bail out if we're not root
if os.getuid() != 0:
    logging.error(color("error", "Bailing out, program must be run with root privileges"))
    sys.exit(3)

# Bail out if required dependencies are not available
programs = ["zypper", "echo", "ps", "sed", "awk", "mkdir", "cat", "dirname", "basename", \
            "readlink", "mount", "chroot", "umount", "sleep", "rm", "env", "findmnt"]
for program in programs:
    out, ret = shell_exec(f"command -v {program}")
    if not out:
        msg = f"Bailing out, missing required dependency {program!r} in PATH ({os.environ.get('PATH')}) " \
            f"for user {os.environ.get('USER')!r}. The following shell tools " \
            f"are required for zypperoni to function: {', '.join(programs)}"
        logging.error(color("error", msg))
        sys.exit(4)

# Check if zypper is already running
pid = None
pid_program = None
if os.path.isfile(ZYPPER_PID_FILE):
    with open(ZYPPER_PID_FILE, "r") as f:
        pid = f.read().strip()
        try:
            pid = int(pid)
        except ValueError:
            pid = None
        if pid:
            pid_program, ret = shell_exec(f"ps -p {pid} | sed '1d' | awk '{{print $4}}'")
            if pid_program:
                msg = f"zypper is already invoked by the application with pid {pid} ({pid_program}).\n" \
                "Close this application before trying again."
                logging.error(color("error", msg))
                sys.exit(5)

# Create secure temp dir
ZYPPERONI_TMP_DIR = tempfile.mkdtemp(dir="/tmp", prefix="zypperoni_")

# Shell commands to prepare temp mounts for zypper refresh
refresh_mount_commands = f"""
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/run;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/var/cache/zypp;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/var/lib/ca-certificates;
if readlink /etc/resolv.conf; then
    RESOLV_PATH=$(readlink /etc/resolv.conf);
    TEMP_DIR={ZYPPERONI_TMP_DIR}/{{uuid}}"$(dirname "$RESOLV_PATH")";
    mkdir -p "$TEMP_DIR";
    cat "$RESOLV_PATH" > "$TEMP_DIR"/"$(basename "$RESOLV_PATH")";
fi;
mount -o bind,ro / {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs;
mount -t devtmpfs none {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/dev;
mount -t tmpfs none {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/tmp;
mount -o bind {ZYPPERONI_TMP_DIR}/{{uuid}}/run {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/run;
mount -o bind {ZYPPERONI_TMP_DIR}/{{uuid}}/var {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var;
mount -o bind /var/cache/zypp {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/cache/zypp;
mount -o bind,ro /var/lib/ca-certificates {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/lib/ca-certificates;
"""

# Shell commands to perform zypper refresh / force-refresh
refresh_shell_commands = f"""
chroot {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs env -i {ZYPPER_ENV} zypper --non-interactive {{refresh_type}} {{repo_alias}};
"""

# Shell commands to prepare temp mounts for zypper download
download_mount_commands = f"""
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/run;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/var/cache/zypp;
mkdir -p {ZYPPERONI_TMP_DIR}/{{uuid}}/var/lib/ca-certificates;
if readlink /etc/resolv.conf; then
    RESOLV_PATH=$(readlink /etc/resolv.conf);
    TEMP_DIR={ZYPPERONI_TMP_DIR}/{{uuid}}"$(dirname "$RESOLV_PATH")";
    mkdir -p "$TEMP_DIR";
    cat "$RESOLV_PATH" > "$TEMP_DIR"/"$(basename "$RESOLV_PATH")";
fi;
mount -o bind,ro / {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs;
mount -o bind {ZYPPERONI_TMP_DIR}/{{uuid}}/run {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/run;
mount -o bind {ZYPPERONI_TMP_DIR}/{{uuid}}/var {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var;
mount -o bind /var/cache/zypp {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/cache/zypp;
mount -o bind,ro /var/lib/ca-certificates {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/lib/ca-certificates;
"""

# Shell commands to perform zypper download
download_shell_commands = f"""
chroot {ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs env -i {ZYPPER_ENV} zypper --non-interactive download {{pkg_name}};
"""

# Dirs to unmount (one per line)
umount_dirs = f"""
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/lib/ca-certificates
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var/cache/zypp
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/var
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/run
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/tmp
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs/dev
{ZYPPERONI_TMP_DIR}/{{uuid}}/rootfs
"""

# Handle command: refresh / ref
if args.command_name in ["refresh", "ref"]:
    # get all enabled repos
    logging.info(color("info", "Getting all enabled repos"))
    REPO_ALIAS = []
    xml_output, ret = shell_exec(f"env -i {ZYPPER_ENV} zypper --non-interactive --no-cd --xmlout repos")
    logging.debug(xml_output)
    get_zypp_lock()
    docroot = ET.fromstring(xml_output)
    for item in docroot.iter("repo"):
        if item.attrib.get("enabled") == "1":
            REPO_ALIAS.append(item.attrib["alias"])
    logging.debug(f"Enabled repos: {REPO_ALIAS}")
    if not REPO_ALIAS:
        logging.info(color("info", "No repos found. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    try:
        asyncio.run(main_task(REPO_ALIAS, args))
    except asyncio.exceptions.CancelledError:
        logging.debug("Received SIGINT for asyncio runner")
    except:
        logging.exception(color("exception", "Unknown exception for asyncio runner"))

# Handle command: dist-upgrade / dup
elif args.command_name in ["dist-upgrade", "dup"]:
    # get info about dup packages
    logging.info(color("info", "Getting all packages to be downloaded for distribution upgrade"))
    xml_output, ret = shell_exec(f"env -i {ZYPPER_ENV} zypper --non-interactive --no-cd --xmlout dist-upgrade --dry-run")
    logging.debug(xml_output)
    if ret == 0 and xml_output.find("Nothing to do") != -1:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    docroot = ET.fromstring(xml_output)
    diff_bytes, download_size_bytes, num_pkgs = (None,) * 3
    for item in docroot.iter('install-summary'):
        download_size_bytes = float(item.attrib["download-size"])
        diff_bytes = float(item.attrib["space-usage-diff"])
        num_pkgs = int(item.attrib["packages-to-change"])
        logging.info(color("info", f"Number of packages to download: {num_pkgs}"))
        logging.info(color("info", f"Total download size: {download_size_bytes/1000**2:.2f} MB"))
        if not args.download_only:
            logging.info(color("info", f"Space usage difference after operation: {diff_bytes/1000**2:+.2f} MB"))
    if num_pkgs:
        # parse all packages from xml output
        DUP_PKG = []
        for item in docroot.iter("solvable"):
            if item.attrib.get("type") == "package":
                DUP_PKG.append(f"{item.attrib['name']}-{item.attrib['edition']}.{item.attrib['arch']}")
        # parse all packages to be removed
        RM_PKG = []
        for rm in docroot.iter("to-remove"):
            for solv in rm.findall("solvable"):
                if solv.get("type") == "package":
                    RM_PKG.append(f"{item.attrib['name']}-{item.attrib['edition']}.{item.attrib['arch']}")
        DUP_PKG = list( set(DUP_PKG) - set(RM_PKG) )
        DUP_PKG.sort()
    else:
        msg = "There are package conflicts that must be manually resolved. Falling back to secondary method of fetching packages to upgrade..."
        logging.warning(color("warning", msg))
        # get info about dup packages from 'zypper lu'
        logging.info(color("info", "Getting all packages to be upgraded"))
        xml_output, ret = shell_exec(f"env -i {ZYPPER_ENV} zypper --non-interactive --no-cd --xmlout list-updates --type package --all")
        logging.debug(xml_output)
        docroot = ET.fromstring(xml_output)
        # parse all packages from xml output
        DUP_PKG = []
        for item in docroot.iter("update"):
            if item.attrib.get("kind") == "package":
                DUP_PKG.append(f"{item.attrib['name']}-{item.attrib['edition']}.{item.attrib['arch']}")
        DUP_PKG.sort()
    get_zypp_lock()
    if not DUP_PKG:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # do not download if all packages are already in cache
    if args.download_only and download_size_bytes == 0:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # proceed straight to dup if all packages are in cache
    if not args.download_only and download_size_bytes == 0:
        zypperoni_cleanup()
        logging.info(color("info", "Zypperoni has finished its tasks. Handing you over to zypper..."))
        command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd dist-upgrade"
        os.system(command)
        sys.exit()
    logging.info(color("info", f"Packages to download: {' '.join(DUP_PKG)}"))
    if not args.no_confirm and not query_yes_no("Would you like to continue?", default="yes"):
        zypperoni_cleanup()
        sys.exit()
    try:
        asyncio.run(main_task(DUP_PKG, args))
    except asyncio.exceptions.CancelledError:
        logging.debug("Received SIGINT for asyncio runner")
    except:
        logging.exception(color("exception", "Unknown exception for asyncio runner"))

# Handle command: install / in
elif args.command_name in ["install", "in"]:
    # get info about install packages
    logging.info(color("info", "Getting packages and their dependencies to be downloaded for installation"))
    xml_output, ret = shell_exec(f"env -i {ZYPPER_ENV} zypper --non-interactive --no-cd --xmlout install --dry-run {' '.join(args.package)}")
    logging.debug(xml_output)
    if ret == 0 and xml_output.find("Nothing to do") != -1:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    get_zypp_lock()
    docroot = ET.fromstring(xml_output)
    NO_ERR = False
    num_pkgs = None
    for item in docroot.iter('install-summary'):
        download_size_bytes = float(item.attrib["download-size"])
        diff_bytes = float(item.attrib["space-usage-diff"])
        num_pkgs = int(item.attrib["packages-to-change"])
        logging.info(color("info", f"Number of packages to download: {num_pkgs}"))
        logging.info(color("info", f"Total download size: {download_size_bytes/1000**2:.2f} MB"))
        if not args.download_only:
            logging.info(color("info", f"Space usage difference after operation: {diff_bytes/1000**2:+.2f} MB"))
        NO_ERR = True
    if not num_pkgs:
        msg = "There are package conflicts that must be manually resolved. See output of:\n" \
            "zypper --non-interactive --no-cd dist-upgrade --dry-run"
        logging.warning(color("warning", msg))
        zypperoni_cleanup()
        sys.exit()
    if not NO_ERR:
        friendly_output = ""
        for item in docroot.iter("message"):
            friendly_output += item.text + "\n"
        logging.error(color("error", f"There was an error processing your request.\n[zypper output]\n{friendly_output.strip()}"))
        zypperoni_cleanup()
        sys.exit(6)
    # parse all packages from xml output
    IN_PKG = []
    for item in docroot.iter("solvable"):
        if item.attrib.get("type") == "package":
            IN_PKG.append(item.attrib["name"])
    # parse all packages to be removed
    RM_PKG = []
    for rm in docroot.iter("to-remove"):
        for solv in rm.findall("solvable"):
            if solv.get("type") == "package":
                RM_PKG.append(solv.get("name"))
    IN_PKG = list( set(IN_PKG) - set(RM_PKG) )
    IN_PKG.sort()
    if not IN_PKG:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # do not download if all packages are already in cache
    if args.download_only and download_size_bytes == 0:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # proceed straight to install if all packages are in cache
    if not args.download_only and download_size_bytes == 0:
        zypperoni_cleanup()
        logging.info(color("info", "Zypperoni has finished its tasks. Handing you over to zypper..."))
        command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd install {' '.join(args.package)}"
        os.system(command)
        sys.exit()
    logging.info(color("info", f"Packages to download: {' '.join(IN_PKG)}"))
    if not args.no_confirm and not query_yes_no("Would you like to continue?", default="yes"):
        zypperoni_cleanup()
        sys.exit()
    try:
        asyncio.run(main_task(IN_PKG, args))
    except asyncio.exceptions.CancelledError:
        logging.debug("Received SIGINT for asyncio runner")
    except:
        logging.exception(color("exception", "Unknown exception for asyncio runner"))

# Handle command: install-new-recommends / inr
elif args.command_name in ["install-new-recommends", "inr"]:
    # get info about recommended install packages
    logging.info(color("info", "Getting new packages and their dependencies to be downloaded for recommended installation"))
    xml_output, ret = shell_exec(f"env -i {ZYPPER_ENV} zypper --non-interactive --no-cd --xmlout install-new-recommends --dry-run")
    logging.debug(xml_output)
    if ret == 0 and xml_output.find("Nothing to do") != -1:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    get_zypp_lock()
    docroot = ET.fromstring(xml_output)
    num_pkgs = None
    for item in docroot.iter('install-summary'):
        download_size_bytes = float(item.attrib["download-size"])
        diff_bytes = float(item.attrib["space-usage-diff"])
        num_pkgs = int(item.attrib["packages-to-change"])
        logging.info(color("info", f"Number of packages to download: {num_pkgs}"))
        logging.info(color("info", f"Total download size: {download_size_bytes/1000**2:.2f} MB"))
        if not args.download_only:
            logging.info(color("info", f"Space usage difference after operation: {diff_bytes/1000**2:+.2f} MB"))
    if not num_pkgs:
        msg = "There are package conflicts that must be manually resolved. See output of:\n" \
            "zypper --non-interactive --no-cd dist-upgrade --dry-run"
        logging.warning(color("warning", msg))
        zypperoni_cleanup()
        sys.exit()
    # parse all packages from xml output
    INR_PKG = []
    for item in docroot.iter("solvable"):
        if item.attrib.get("type") == "package":
            INR_PKG.append(item.attrib["name"])
    # parse all packages to be removed
    RM_PKG = []
    for rm in docroot.iter("to-remove"):
        for solv in rm.findall("solvable"):
            if solv.get("type") == "package":
                RM_PKG.append(solv.get("name"))
    INR_PKG = list( set(INR_PKG) - set(RM_PKG) )
    INR_PKG.sort()
    if not INR_PKG:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # do not download if all packages are already in cache
    if args.download_only and download_size_bytes == 0:
        logging.info(color("info", "Nothing to do. Exiting..."))
        zypperoni_cleanup()
        sys.exit()
    # proceed straight to inr if all packages are in cache
    if not args.download_only and download_size_bytes == 0:
        zypperoni_cleanup()
        logging.info(color("info", "Zypperoni has finished its tasks. Handing you over to zypper..."))
        command = f"env {ZYPPER_ENV} zypper {'--non-interactive' if args.no_confirm else ''} --no-cd install-new-recommends"
        os.system(command)
        sys.exit()
    logging.info(color("info", f"Packages to download: {' '.join(INR_PKG)}"))
    if not args.no_confirm and not query_yes_no("Would you like to continue?", default="yes"):
        zypperoni_cleanup()
        sys.exit()
    try:
        asyncio.run(main_task(INR_PKG, args))
    except asyncio.exceptions.CancelledError:
        logging.debug("Received SIGINT for asyncio runner")
    except:
        logging.exception(color("exception", "Unknown exception for asyncio runner"))
