#!/usr/bin/python3
# This file is part of the isoinspector distribution (https://git.altlinux.org/people/dshein/packages/isoinspector.git).
# Copyright (c) 2022 BaseALT Ltd
#
# This program 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, version 3.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.

import os
import re
import rpm
import sys
import json
import lzma
import mmh3
import time
import shutil
import logging
import argparse
import requests
import tempfile
import subprocess

from dataclasses import dataclass
from io import DEFAULT_BUFFER_SIZE
from pathlib import Path
from typing import Any, Union, Literal, Optional
from uuid import uuid4

NAME = "isoinspector"
VERSION = "0.2.3"

RUN_COMMAND_TIMEOUT = 30
REQUEST_OK_CODES = (
    200,
    201,
)
IMG_REQUIRED_EXECUTABLES = ("unxz", "umount", "guestmount")
ISO_REQUIRED_EXECUTABLES = ("umount", "fuseiso", "squashfuse")

REQUEST_RETRY_COUNT = 3
REQUEST_RETRY_TIMEOUT = 1
REQUEST_CONNECT_TIMEOUT = 3
REQUEST_READOUT_TIMEOUT = 10

RDB_BASE_URL = "https://rdb.altlinux.org"
RDB_INSPECT_SP = "api/image/inspect/sp"
RDB_INSPECT_REGULAR = "api/image/inspect/regular"

FILE_PKGS_IN_TASKS = "in_tasks.json"
FILE_PKGS_NOT_IN_DB = "not_in_db.json"

IMAGE_TYPES = ("regular", "sp")

IMG_LOCALTMP_PREFIX = "tmp_img"
IMG_RPMDB_PREFIX = "var/lib/rpm"

ALLOW_FILE_SUFFIXES_FALLBACK = True

#  custom types
_StringOrPath = Union[str, Path]
_Logger = logging.Logger


# exceptions
class RunCommandError(Exception):
    pass


class ImageMounterError(Exception):
    pass


class ImageProcessingError(Exception):
    pass


class APIRequestError(Exception):
    pass


class ImageInspectorError(Exception):
    pass


class RPMDBOpenError(Exception):
    def __init__(self, path: _StringOrPath = ""):
        self.path = str(path)
        super().__init__(f"Failed to open RPM DB in {self.path}")


class Utils:
    """Utility class for ISO image contents processing."""

    @staticmethod
    def is_iso_image(file_path: _StringOrPath) -> bool:
        try:
            with open(file_path, "rb") as infile:
                infile.seek(0x8000)
                header = infile.read(6)
                # Validate Volume Descriptor Type (0x00-0x02) and identifier
                if (
                    header[0:1] in (b"\x00", b"\x01", b"\x02")
                    and header[1:6] == b"CD001"
                ):
                    return True
            return ALLOW_FILE_SUFFIXES_FALLBACK and str(file_path).endswith(".iso")
        except OSError:
            return False

    @staticmethod
    def is_xz_compressed(file_path: _StringOrPath) -> bool:
        try:
            with open(file_path, "rb") as f:
                magic = f.read(6)
                # XZ magic: 0xFD 0x37 0x7A 0x58 0x5A 0x00
                if magic == b"\xfd\x37\x7a\x58\x5a\x00":
                    return True
            return ALLOW_FILE_SUFFIXES_FALLBACK and str(file_path).endswith(".img.xz")
        except (IOError, OSError):
            return False

    @staticmethod
    def run_command(
        *args,
        env: Optional[dict[str, str]] = None,
        raise_on_error: bool = False,
        timeout: Optional[float] = None,
    ) -> tuple[str, str, str, int]:
        """Run external program using subprocess module."""

        cmdline = " ".join([*args])

        try:
            env_ = env if env is not None else os.environ.copy()
            sub = subprocess.run(
                [*args],
                env=env_,
                capture_output=True,
                text=True,
                check=raise_on_error,
                timeout=timeout,
            )
        except subprocess.CalledProcessError as e:
            raise RunCommandError(
                f"Subprocess returned code {e.returncode}: {e.stderr}"
            ) from e
        except subprocess.TimeoutExpired as e:
            raise RunCommandError(f"Subprocess has timed out after {timeout}s") from e

        return cmdline, sub.stdout, sub.stderr, sub.returncode

    @staticmethod
    def cvt(value: Any, value_type: type = str) -> Any:
        """Converts values from RPM header with proper defaults."""

        if isinstance(value, bytes) and value_type is str:
            return value.decode("latin-1")

        if isinstance(value, list):
            return [Utils.cvt(i) for i in value]

        if value is None:
            if value_type is bytes:
                return b""
            if value_type is str:
                return ""
            if value_type is int:
                return 0

        return value

    @staticmethod
    def detect_arch(hdr) -> str:
        package_name: str = Utils.cvt(hdr[rpm.RPMTAG_NAME])  # type: ignore

        if package_name.startswith("i586-"):
            return "x86_64-i586"

        return Utils.cvt(hdr[rpm.RPMTAG_ARCH])  # type: ignore

    @staticmethod
    def mmhash(val: Any) -> int:
        a, b = mmh3.hash64(val, signed=False)
        return a ^ b

    @staticmethod
    def snowflake_id_pkg(hdr: dict, epoch: int = 1_000_000_000) -> int:
        def _snowflake_id(timestamp: int, lower_32bit: int, epoch: int) -> int:
            sf_ts = timestamp - epoch
            return (sf_ts << 32) | (lower_32bit & 0xFFFFFFFF)

        sha1: bytes = bytes.fromhex(Utils.cvt(hdr[rpm.RPMTAG_SHA1HEADER]))  # type: ignore
        md5: bytes = hdr[rpm.RPMTAG_SIGMD5]  # type: ignore
        gpg: bytes = hdr[rpm.RPMTAG_SIGGPG]  # type: ignore

        if md5 is None:
            md5 = b""

        if gpg is None:
            gpg = b""

        # combine multiple GPG signs in one
        if isinstance(gpg, list):
            _gpg = b""
            for k in gpg:
                _gpg += k  # type: ignore
            gpg = _gpg

        return _snowflake_id(
            timestamp=Utils.cvt(hdr[rpm.RPMTAG_BUILDTIME], int),  # type: ignore
            lower_32bit=mmh3.hash((sha1 + md5 + gpg), signed=False),
            epoch=epoch,
        )

    @staticmethod
    def dump_to_json(obj: Any, file: _StringOrPath) -> None:
        Path.joinpath(Path.cwd(), file).write_text(
            json.dumps(obj, indent=2, sort_keys=True, default=str)
        )


@dataclass
class ImageMounter:
    """Handles ISO, SquashFS and IMG images mounting and unmounting."""

    name: str
    type: str
    path: str
    ismount: bool
    _tmpdir: tempfile.TemporaryDirectory
    _image_path: str

    def __init__(
        self,
        image_name: str,
        image_path: str,
        image_type: Literal["iso", "squashfs", "img"],
    ):
        self.name = image_name
        self.type = image_type
        self._image_path = image_path
        self._tmpdir = tempfile.TemporaryDirectory()
        self._localtmpfile: Optional[Path] = None
        self.path = self._tmpdir.name
        self.ismount = False

    def _run_command(self, *args, env=None) -> tuple[str, str, int]:
        _, stdout, stderr, retcode = Utils.run_command(
            *args, env=env, raise_on_error=True, timeout=RUN_COMMAND_TIMEOUT
        )
        return (stdout, stderr, retcode)

    def __mount_iso(self) -> None:
        self._run_command("fuseiso", self._image_path, self.path)

    def __mount_sqfs(self) -> None:
        self._run_command("squashfuse", self._image_path, self.path)

    def __mount_img(self) -> None:
        image_path = self._image_path
        # check if image is compressed
        if Utils.is_xz_compressed(image_path):
            # uncompress image to local temporary folder
            # 1. check if there is enogh space for uncompressed image
            # 1.1 get uncompressed archive size by pasing 'unxz -lv' output
            sout, _, _ = self._run_command("unxz", "-lv", image_path)

            u_size = 0
            for line in sout.splitlines():
                if line.strip().startswith("Uncompressed size:"):
                    u_size = int(line.replace("\u202f", "").split("(")[-1][:-3])
                    break

            if u_size == 0:
                raise ImageProcessingError("Failed to get uncompressed image size")

            # 1.2 check if there is enough space in user home directory
            homedir = Path.home()
            st_ = os.statvfs(homedir)
            freespace = st_.f_bsize * st_.f_bavail
            if (u_size * 1.1) > freespace:
                raise ImageProcessingError(
                    "Not enough space to umcompress filesystem image"
                )

            # 2. uncompress image
            # 2.1 setup temporary file name
            self._localtmpfile = homedir.joinpath(
                "_".join((IMG_LOCALTMP_PREFIX, str(uuid4())))
            )

            # 2.2 uncompress 'img.xz' to temporary file
            with lzma.open(image_path, "rb") as archive:
                with open(self._localtmpfile, "wb") as tmpfile:
                    for chunk in iter(lambda: archive.read(DEFAULT_BUFFER_SIZE), b""):
                        tmpfile.write(chunk)

        # mount filesystem image
        if self._localtmpfile is not None:
            image_path = str(self._localtmpfile)

        # supbrocess args
        args = (
            "guestmount",
            "-a",
            image_path,
            "-i",
            "--ro",
            self.path,
        )
        # pass environment variables to subprocess
        env = os.environ.copy()
        env["LIBGUESTFS_BACKEND"] = "direct"

        self._run_command(*args, env=env)

    def _mount(self) -> None:
        if self.type == "iso":
            self.__mount_iso()
        elif self.type == "squashfs":
            self.__mount_sqfs()
        elif self.type == "img":
            self.__mount_img()
        else:
            raise ImageMounterError(f"Filesystem image type {self.type} not supported")

        self.ismount = True

    def _unmount(self) -> None:
        self._run_command("umount", self.path)

    def open(self) -> None:
        if not self.ismount:
            try:
                self._mount()
            except Exception as e:
                self._tmpdir.cleanup()
                if self._localtmpfile is not None:
                    self._localtmpfile.unlink(missing_ok=True)
                    self._localtmpfile = None
                raise ImageMounterError(
                    f"Failed to mount {self.type} image {self._image_path} to {self.path}"
                ) from e

    def close(self) -> None:
        if self.ismount:
            try:
                self._unmount()
            except Exception as e:
                raise ImageMounterError(
                    f"Failed to unmount {self.type} image at {self.path}"
                ) from e
            finally:
                self.ismount = False

            self._tmpdir.cleanup()

            if self._localtmpfile is not None:
                self._localtmpfile.unlink(missing_ok=True)
                self._localtmpfile = None


@dataclass
class Package:
    hash: int = 0
    name: str = ""
    arch: str = ""
    iname: Path = Path()
    epoch: int = 0
    version: str = ""
    release: str = ""
    disttag: str = ""
    is_srpm: bool = False
    buildtime: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "pkg_arch": self.arch,
            "pkg_hash": str(self.hash),
            "pkg_name": self.name,
            "pkg_epoch": self.epoch,
            "pkg_version": self.version,
            "pkg_release": self.release,
            "pkg_disttag": self.disttag,
            "pkg_buildtime": self.buildtime,
        }


@dataclass
class SquashFSImage:
    name: str
    mount: ImageMounter
    packages: list[Package]


@dataclass
class ISOImage:
    name: str
    path: str
    size: int
    mount: ImageMounter
    packages: list[Package]


@dataclass
class IMGImage(ISOImage):
    pass


class SuppressStdoutStderr:
    """Context manager that supress all stdout and stderr from any function wraped in."""

    def __init__(self):
        self.null_fds = [os.open(os.devnull, os.O_RDWR) for _ in range(2)]
        self.save_fds = [os.dup(1), os.dup(2)]

    def __enter__(self):
        os.dup2(self.null_fds[0], 1)
        os.dup2(self.null_fds[1], 2)

    def __exit__(self, *_):
        os.dup2(self.save_fds[0], 1)
        os.dup2(self.save_fds[1], 2)
        for fd in self.null_fds + self.save_fds:
            os.close(fd)


class RPMDB:
    """Reads RPM database and retrieves packages info."""

    def __init__(self, dbpath: _StringOrPath):
        self._dbpath = str(dbpath)
        self._packages_list: list[Package] = []
        self._packages_count: int = 0

        self._read_rpm_db()

    @staticmethod
    def _get_package(hdr: dict) -> Package:
        return Package(
            iname=Path(),
            hash=Utils.snowflake_id_pkg(hdr),
            name=Utils.cvt(hdr[rpm.RPMTAG_NAME]),  # type: ignore
            epoch=Utils.cvt(hdr[rpm.RPMTAG_EPOCH], int),  # type: ignore
            version=Utils.cvt(hdr[rpm.RPMTAG_VERSION]),  # type: ignore
            release=Utils.cvt(hdr[rpm.RPMTAG_RELEASE]),  # type: ignore
            arch=Utils.detect_arch(hdr),
            disttag=Utils.cvt(hdr[rpm.RPMTAG_DISTTAG]),  # type: ignore
            is_srpm=bool(hdr[rpm.RPMTAG_SOURCEPACKAGE]),  # type: ignore
            buildtime=Utils.cvt(hdr[rpm.RPMTAG_BUILDTIME]),  # type: ignore
        )

    @staticmethod
    def get_package_info(package: _StringOrPath) -> Package:
        package_iname = Path(package)
        ts = rpm.TransactionSet()
        hdr = ts.hdrFromFdno(str(package_iname))
        pkg = RPMDB._get_package(hdr)
        pkg.iname = package_iname
        return pkg

    def _read_rpm_db(self) -> None:
        rpm.addMacro("_dbpath", self._dbpath)  # type: ignore
        ts = rpm.TransactionSet()

        with SuppressStdoutStderr():
            err = ts.openDB()

        if err != 0:
            raise RPMDBOpenError(self._dbpath)

        rpm.delMacro("_dbpath")  # type: ignore
        hdrs = ts.dbMatch()

        for hdr in hdrs:
            self._packages_count += 1
            self._packages_list.append(RPMDB._get_package(hdr))

    @property
    def packages(self):
        return self._packages_list

    @property
    def count(self):
        return self._packages_count


class IMG:
    """Provides access to IMG images contents."""

    def __init__(self, name: str, path: _StringOrPath, logger: _Logger) -> None:
        self.parsed = False
        self.logger = logger
        self.name = name
        self.path = str(path)
        self._img = IMGImage(
            name=self.name,
            path=self.path,
            size=Path(self.path).stat().st_size,
            mount=ImageMounter(self.name, self.path, "img"),
            packages=list(),
        )

    def _close(self) -> None:
        self.logger.info(f"Closing {self._img.name} image")
        if self._img.mount.ismount:
            self._img.mount.close()

    def _check_system_executables(self):
        not_found_ = []
        for executable in IMG_REQUIRED_EXECUTABLES:
            if shutil.which(executable) is None:
                self.logger.error(f"Executable '{executable}' not found")
                not_found_.append(executable)
        if not_found_:
            not_found_ = ", ".join(not_found_)
            raise ImageProcessingError(f"Executable not found in system : {not_found_}")

    def _open_image(self):
        try:
            self.logger.info(f"Opening {self.name} filesystem image")
            self._img.mount.open()
        except Exception as e:
            self.logger.error(f"Failed to mount filesystem image {self.path}")
            raise ImageProcessingError(self.path) from e

    def _process_image(self):
        p = Path(self._img.mount.path)

        # read packages from RPMDB
        self.logger.debug("Reading filesystem image RPM packages")
        try:
            rpmdb = RPMDB(p.joinpath(IMG_RPMDB_PREFIX))
            self._img.packages = rpmdb.packages
            self.logger.info(
                f"Collected {rpmdb.count} RPM packages from '{self.name}' filesystem image"
            )
        except RPMDBOpenError:
            self.logger.error(
                f"No RPM packages found in '{self.name}' filesystem image"
            )
            raise ImageProcessingError("No packages found")

    def run(self):
        self.logger.info(f"Processing {self.name} filesystem image")
        self._check_system_executables()
        try:
            self._open_image()
            self._process_image()
            self.parsed = True
        except ImageProcessingError:
            self.logger.error(
                "Error occured while processing filesystem image", exc_info=True
            )
            raise
        finally:
            self._close()

    @property
    def img(self) -> IMGImage:
        if not self.parsed:
            self.run()
        return self._img


class ISO:
    """Provides access to ISO and SquashFS images contents."""

    def __init__(self, name: str, path: _StringOrPath, logger: _Logger) -> None:
        self.parsed = False
        self.logger = logger
        self.name = name
        self.path = str(path)
        self._iso = ISOImage(
            name=self.name,
            path=self.path,
            size=Path(self.path).stat().st_size,
            mount=ImageMounter(self.name, self.path, "iso"),
            packages=list(),
        )
        self._sqfs: list[SquashFSImage] = list()

    def _close(self) -> None:
        self.logger.info(f"Closing {self._iso.name} ISO image")
        for sqfs in self._sqfs:
            if sqfs.mount.ismount:
                sqfs.mount.close()
        if self._iso.mount.ismount:
            self._iso.mount.close()

    def _check_system_executables(self):
        not_found_ = []
        for executable in ISO_REQUIRED_EXECUTABLES:
            if shutil.which(executable) is None:
                self.logger.error(f"Executable '{executable}' not found")
                not_found_.append(executable)
        if not_found_:
            not_found_ = ", ".join(not_found_)
            raise ImageProcessingError(f"Executable not found in system : {not_found_}")

    def _open_iso(self) -> None:
        if not os.path.isfile(self._iso.path):
            self.logger.error(f"{self._iso.path} is not an ISO image")
            raise ImageProcessingError(f"{self._iso.path} is not an ISO image")

        try:
            self.logger.info(f"Opening {self._iso.name} ISO image")
            self._iso.mount.open()
        except Exception as e:
            self.logger.error(f"Failed to mount ISO image {self._iso.path}")
            raise ImageProcessingError(
                f"Failed to mount ISO image {self._iso.path}"
            ) from e

        self.logger.info("Processing SquashFS images from ISO")
        for sqfs_name in ("live", "rescue", "altinst"):
            sqfs_path = os.path.join(self._iso.mount.path, sqfs_name)
            if os.path.isfile(sqfs_path):
                self.logger.info(
                    f"Found '{sqfs_name}' SquashFS image in {self._iso.name}"
                )
                sqfs = SquashFSImage(
                    name=sqfs_name,
                    packages=[],
                    mount=ImageMounter(sqfs_name, sqfs_path, "squashfs"),
                )
                try:
                    self.logger.info(f"Opening '{sqfs_name}' SquashFS image")
                    sqfs.mount.open()
                    self._sqfs.append(sqfs)
                except Exception as e:
                    self.logger.error(f"Failed to mount '{sqfs_name}' SquashFS image")
                    raise ImageProcessingError(
                        f"Failed to mount '{sqfs_name}' SquashFS image"
                    ) from e

    def _process_iso(self):
        self.logger.info("Gathering ISO image RPM packages information")
        iso_rpms_dir = Path(self._iso.mount.path).joinpath("ALTLinux")

        if iso_rpms_dir.is_dir():
            for pkg in (
                p
                for p in iso_rpms_dir.joinpath("RPMS.main").iterdir()
                if p.is_file() and p.name.endswith(".rpm")
            ):
                self._iso.packages.append(RPMDB.get_package_info(pkg))

        self.logger.info(
            f"Collected {len(self._iso.packages)} RPM packages "
            f"from {self.path}/ALTLinux/RPMS.main"
        )

    def _process_squashfs(self):
        self.logger.info("Gathering SquashFS images meta information")
        cwd = Path.cwd()

        for sqfs in self._sqfs:
            self.logger.info(f"Processing '{sqfs.name}' SquashFS image")
            self.logger.debug("Reading SquashFs image RPM packages")

            try:
                rpmdb = RPMDB(str(Path(sqfs.mount.path).joinpath(IMG_RPMDB_PREFIX)))
                sqfs.packages = rpmdb.packages
                self.logger.info(
                    f"Collected {rpmdb.count} RPM packages from '{sqfs.name}' SquashFS image"
                )
            except RPMDBOpenError:
                self.logger.info(
                    f"No RPM packages found in '{sqfs.name}' SquashFS image"
                )

        os.chdir(cwd)

    def run(self):
        self.logger.info(f"Processing {self._iso.name} ISO image")
        self._check_system_executables()
        try:
            self._open_iso()
            self._process_iso()
            self._process_squashfs()
            self.parsed = True
        except ImageProcessingError:
            self.logger.error("Error occured while processing ISO image", exc_info=True)
            raise
        finally:
            self._close()

    @property
    def iso(self) -> ISOImage:
        if not self.parsed:
            self.run()
        return self._iso

    @property
    def sqfs(self) -> list[SquashFSImage]:
        if not self.parsed:
            self.run()
        return self._sqfs


@dataclass
class Config:
    url: str
    logger: _Logger
    store_files: bool = False
    dump_to_json: bool = False


class ISOInspector:
    """Inspects RPM packages from ISO distribution image with ALTRepo API."""

    def __init__(
        self,
        image_path: _StringOrPath,
        image_branch: str,
        image_type: str,
        config: Config,
    ) -> None:
        if image_type not in ("regular", "sp"):
            raise ValueError(f"Invalid distribution type {image_type}")

        self.type = image_type
        self.path = Path(image_path)
        self.name = self.path.name
        self._name = self.name.replace(" ", "_")
        self.branch = image_branch
        self.config = config
        self.packages: dict[str, list[dict[str, Any]]] = {}
        self.result: dict[str, dict[str, Any]] = {}
        self.logger = self.config.logger

        self._img_type: Literal["iso", "img"]

        if Utils.is_iso_image(self.path):
            self._img_type = "iso"
            self.image = ISO(name=self.path.name, path=self.path, logger=self.logger)
        else:
            self._img_type = "img"
            self.image = IMG(name=self.path.name, path=self.path, logger=self.logger)

    def _send_request(
        self, url: str, params: dict, payload: dict, method: str = "GET", retry: int = 1
    ) -> requests.Response:
        response: requests.Response = None  # type: ignore
        timeout = REQUEST_RETRY_TIMEOUT

        for i in range(retry):
            if method not in ("GET", "POST"):
                raise ValueError(f"Method {method} not supported")

            try:
                response = requests.request(
                    method,
                    url=url,
                    params=params,
                    json=payload,
                    timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READOUT_TIMEOUT),
                )
            except requests.Timeout:
                self.logger.debug("Request timed out")
            except requests.RequestException as e:
                err = f"Request failed with {repr(e)}"
                self.logger.error(err)
                raise APIRequestError(err)

            if response is not None and response.status_code in REQUEST_OK_CODES:
                self.logger.debug(
                    f"Request elapsed {response.elapsed.total_seconds():.3f} seconds"
                )
                return response

            timeout *= i + 1
            self.logger.debug(f"Retrying to send request after {timeout} seconds")
            time.sleep(timeout)

        err = f"Request to {url} failed with code {response.status_code} after {retry} attempts"
        self.logger.error(err)
        raise ImageInspectorError(err)

    def _get_result_from_api(self, image: str, api_endpoint: str) -> dict[str, Any]:
        return self._send_request(
            url="/".join((self.config.url, api_endpoint)),
            params={},
            method="POST",
            retry=REQUEST_RETRY_COUNT,
            payload={"branch": self.branch, "packages": self.packages[image]},
        ).json()

    def _dump_api_reponse(self, image: str, response: dict[str, Any]) -> None:
        if self.config.dump_to_json:
            fname = f"{self._name}_{image}.json"
            self.logger.info(f"Dump package inspection results to file {fname}")
            Utils.dump_to_json(response, fname)

    def _dump_pkgs_in_tasks(self, image: str, packages: list[Any], qtty: int) -> None:
        if self.config.store_files and qtty != 0:
            fname = f"{self._name}_{image}_{FILE_PKGS_IN_TASKS}"
            Utils.dump_to_json(packages, fname)
            self.logger.info(
                f"{qtty} packages found in build tasks stored to '{fname}'"
            )

    def _dump_pkgs_not_in_db(self, image: str, packages: list[Any], qtty: int) -> None:
        if self.config.store_files and qtty != 0:
            fname = f"{self._name}_{image}_{FILE_PKGS_NOT_IN_DB}"
            Utils.dump_to_json(packages, fname)
            self.logger.info(
                f"{qtty} packages not found in database stored to '{fname}'"
            )

    def inspect_regular(self, image: str) -> None:
        response = self._get_result_from_api(image, RDB_INSPECT_REGULAR)
        self._dump_api_reponse(image, response)

        self.result[image] = {"image": image, "result": "success"}

        if response["not_in_branch"] == 0:
            self.logger.info(f"All packages found in branch for '{image}'")
            return

        self.result[image].update(
            {
                "packages": {
                    "in_tasks": [],
                    "not_in_db": [],
                }
            }
        )

        self.result[image][
            "result"
        ] = f"found {response['not_in_branch']} invalid packages"

        self.logger.info(
            f'{response["not_in_branch"]} not found in branch for '
            f'{response["input_pakages"]} from \'{image}\''
        )

        if response["found_in_tasks"] != 0:
            self.result[image]["packages"]["in_tasks"] = response["packages_in_tasks"]

            self._dump_pkgs_in_tasks(
                image, response["packages_in_tasks"], response["found_in_tasks"]
            )

        if response["not_found_in_db"] != 0:
            self.result[image]["packages"]["not_in_db"] = response["packages_not_in_db"]

            self._dump_pkgs_not_in_db(
                image, response["packages_not_in_db"], response["not_found_in_db"]
            )

    def inspect_sp(self, image: str) -> None:
        response = self._get_result_from_api(image, RDB_INSPECT_SP)
        self._dump_api_reponse(image, response)

        self.result[image] = {"image": image, "result": "success"}

        if response["not_in_branch"] == 0:
            self.logger.info(f"All packages found in branch for '{image}'")
            return

        self.result[image].update(
            {
                "packages": {
                    "in_tasks": [],
                    "not_in_db": [],
                }
            }
        )

        self.result[image][
            "result"
        ] = f"found {response['not_in_branch']} invalid packages"

        self.logger.info(
            f'{response["not_in_branch"]} packages not found in branch '
            f'for {response["input_pakages"]} packages from \'{image}\''
        )

        if response["found_in_tasks"] != 0:
            self.result[image]["packages"]["in_tasks"] = [
                p for p in response["packages"] if p["found_in"].startswith("task")
            ]

            self._dump_pkgs_in_tasks(
                image,
                self.result[image]["packages"]["in_tasks"],
                response["found_in_tasks"],
            )

        if response["not_found_in_db"] != 0:
            self.result[image]["packages"]["not_in_db"] = [
                p
                for p in response["packages"]
                if p["found_in"] in ("not found", "last branch")
            ]

            self._dump_pkgs_not_in_db(
                image,
                self.result[image]["packages"]["not_in_db"],
                response["not_found_in_db"],
            )

    def run(self) -> None:
        # 0. mount and parse an image
        self.image.run()

        # 1. collect packages in JSON-like format
        if self._img_type == "iso":
            assert isinstance(self.image, ISO)

            if self.image.iso.packages:
                self.packages["iso"] = []
                for p in self.image.iso.packages:
                    self.packages["iso"].append(p.to_dict())

                self.logger.info(
                    f"Found {len(self.image.iso.packages)} RPM packages from ISO image"
                )

            for sqfs in self.image.sqfs:
                if not sqfs.packages:
                    continue

                self.packages[sqfs.name] = []
                for p in sqfs.packages:
                    self.packages[sqfs.name].append(p.to_dict())

                self.logger.info(
                    f"Found {len(sqfs.packages)} RPM packages from '{sqfs.name}' SquashFS image"
                )
        else:
            assert isinstance(self.image, IMG)

            self.packages["img"] = []
            for p in self.image.img.packages:
                self.packages["img"].append(p.to_dict())

            self.logger.info(
                f"Found {len(self.image.img.packages)} RPM packages from IMG image"
            )

        # 2. dump packages to JSON
        if self.config.dump_to_json:
            Utils.dump_to_json(self.packages, f"{self._name}_packages.json")

        # 3. check packages through API
        for image in ("img", "iso", "live", "rescue", "altinst"):
            if image not in self.packages:
                continue

            self.result[image] = {}

            if self.type == "sp":
                self.inspect_sp(image)
            else:
                self.inspect_regular(image)

        # 4. send result to stdout
        print(json.dumps(self.result), file=sys.stdout)


def valid_url(url: str) -> str:
    """Check if string is valid URL."""

    url_match = re.compile(r"^((https|http):\/\/)?[a-zA-Z0-9\.\_\-]+(:[0-9]{2,5})?\/?$")

    if not url_match.search(url):
        raise argparse.ArgumentTypeError("Not a valid URL")

    return url


def get_args():
    parser = argparse.ArgumentParser(
        prog=NAME,
        description="Inspects RPM packages from ISO image with current branch state through RepoDB API",
    )

    parser.add_argument("path", type=str, help="Path to ISO image file")
    parser.add_argument(
        "--branch", required=True, type=str, help="Filesystem image base branch name"
    )
    parser.add_argument(
        "--type", required=True, type=str, choices=IMAGE_TYPES, help="Branch type"
    )
    parser.add_argument(
        "-U",
        "--url",
        required=False,
        type=valid_url,
        default=RDB_BASE_URL,
        help="RepoDB API URL",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbosity",
        default=0,
        help="verbose output (repeat for increased verbosity)",
    )
    parser.add_argument(
        "-s",
        "--silent",
        action="store_const",
        const=-1,
        default=0,
        dest="verbosity",
        help="quiet output (show errors only)",
    )
    parser.add_argument(
        "-J", "--dumpjson", action="store_true", help="Dump API response to JSON files"
    )
    parser.add_argument(
        "-F",
        "--files",
        action="store_true",
        help="Dump information about invalid packages to JSON files",
    )

    return parser.parse_args()


def setup_logging(verbosity: int) -> _Logger:
    base_loglevel = 30
    verbosity = min(verbosity, 2)
    loglevel = base_loglevel - (verbosity * 10)

    logging.basicConfig(
        level=loglevel, format="%(asctime)s [%(levelname)s] %(message)s"
    )

    return logging.getLogger()


def main():
    args = get_args()
    logger = setup_logging(args.verbosity)

    inspector = ISOInspector(
        image_path=args.path,
        image_branch=args.branch,
        image_type=args.type,
        config=Config(
            url=args.url.rstrip("/"),
            logger=logger,
            store_files=args.files,
            dump_to_json=args.dumpjson,
        ),
    )

    try:
        inspector.run()
    except Exception as e:
        logger.error(f"An exception occured while processing ISO image packages: {e}")
        logger.debug("", exc_info=True)
        sys.exit(1)


if __name__ == "__main__":
    main()
