#!/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 mmh3
import time
import shutil
import logging
import argparse
import requests
import tempfile
import subprocess
from dataclasses import dataclass
from typing import Any, Union, Optional
from pathlib import Path


# logger
class FakeLogger:
    """Fake logger class."""

    def __init__(self, name: str, level: Any = None) -> None:
        pass

    def debug(self, msg: str, *args: Any, **kwargs: Any) -> None:
        pass

    def info(self, msg: str, *args: Any, **kwargs: Any) -> None:
        pass

    def warning(self, msg: str, *args: Any, **kwargs: Any) -> None:
        pass

    def error(self, msg: str, *args: Any, **kwargs: Any) -> None:
        pass

    def critical(self, msg: str, *args: Any, **kwargs: Any) -> None:
        pass

    def setLevel(self, level: Union[int, str]) -> None:
        pass


# module constants
NAME = "isoinspector"
RUN_COMMAND_TIMEOUT = 10
REQUEST_OK_CODES = (
    200,
    201,
)
REQUIRED_EXECUTABLES = (
    "umount",
    "fuseiso",
    "squashfuse",
)

DEFAULT_LOGGER = FakeLogger(name="")
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")

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

# 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}")


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

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

        cmdline = " ".join([*args])
        logger.debug(f"Run command: {cmdline}")
        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:
            logger.error(f"subprocess commandline: {e.cmd} returned {e.returncode}")
            logger.error(f"subprocess stdout: {e.stdout}")
            logger.error(f"subprocess stderr: {e.stderr}")
            raise RunCommandError("Subprocess returned non zero code") from e
        except subprocess.TimeoutExpired as e:
            logger.error(
                f"subprocess commandline: {e.cmd}, {timeout} seconds timeout expired"
            )
            raise RunCommandError("Subprocess has timed out") from e
        return cmdline, sub.stdout, sub.stderr, sub.returncode

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

        if isinstance(b, bytes) and t is str:
            return b.decode("latin-1")
        if isinstance(b, list):
            return [Utils.cvt(i) for i in b]
        if b is None:
            if t is bytes:
                return b""
            if t is str:
                return ""
            if t is int:
                return 0
        return b

    @staticmethod
    def detect_arch(hdr):
        package_name = 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(timestamp: int, lower_32bit: int, epoch: int) -> int:
        sf_ts = timestamp - epoch
        sf_id = (sf_ts << 32) | (lower_32bit & 0xFFFFFFFF)
        return sf_id

    @staticmethod
    def snowflake_id_pkg(hdr: dict, epoch: int = 1_000_000_000) -> int:
        """Genarates showflake-like ID using data from RPM package header object.
        Returns 64 bit wide unsigned integer:
            - most significant 32 bits package build time delta from epoch
            - less significant 32 bits are mutmurHash from package sign header (SHA1 + MD5 + GPG)

        Args:
            hdr (dict): RPM package header object
            epoch (int, optional): Base epoch for timestamp part calculation. Defaults to 1_000_000_000.

        Returns:
            int: showflake like ID
        """

        buildtime: int = Utils.cvt(hdr[rpm.RPMTAG_BUILDTIME], int)  # type: ignore
        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_

        data = sha1 + md5 + gpg
        sf_hash = mmh3.hash(data, signed=False)
        return Utils._snowflake_id(
            timestamp=buildtime, lower_32bit=sf_hash, epoch=epoch
        )

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


@dataclass
class ImageMounter:
    """Handles ISO and SquashFS 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: str,
    ):
        self.name = image_name
        self.type = image_type

        if image_type not in ("iso", "squashfs"):
            raise ImageMounterError(f"Unsupported filesystem image type {image_type}")

        self._image_path = image_path
        self._tmpdir = tempfile.TemporaryDirectory()
        self.path = self._tmpdir.name
        self.ismount = False

    def _run_command(self, *args):
        try:
            _, _, _, _ = Utils.run_command(
                *args,
                raise_on_error=True,
                timeout=RUN_COMMAND_TIMEOUT,
            )
        except RunCommandError as e:
            raise ImageMounterError from e

    def _unmount(self):
        try:
            self._run_command("umount", self.path)
        except RunCommandError as e:
            raise ImageMounterError(
                f"Failed to unmount image {self._image_path} from {self.path}"
            ) from e

    def _mount_iso(self):
        try:
            self._run_command("fuseiso", self._image_path, self.path)
        except RunCommandError as e:
            raise ImageMounterError(
                f"Failed to nmount image {self._image_path} in {self.path}"
            ) from e

    def _mount_sqfs(self):
        try:
            self._run_command("squashfuse", self._image_path, self.path)
        except RunCommandError as e:
            raise ImageMounterError(
                f"Failed to nmount image {self._image_path} in {self.path}"
            ) from e

    def _mount(self):
        if self.type == "iso":
            self._mount_iso()
        elif self.type == "squashfs":
            self._mount_sqfs()

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

    def close(self):
        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()


@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


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


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


class SupressStdoutStderr:
    """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 x 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 RPMDBPackages:
    """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.ts = 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 = RPMDBPackages._get_package(hdr)
        pkg.iname = package_iname
        return pkg

    def _read_rpm_db(self):
        rpm.addMacro("_dbpath", self.dbpath)  # type: ignore
        ts = rpm.TransactionSet()
        with SupressStdoutStderr():
            r = ts.openDB()
        if r != 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(RPMDBPackages._get_package(hdr))

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

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


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

    def __init__(
        self, iso_name: str, iso_path: _StringOrPath, logger: _Logger = DEFAULT_LOGGER
    ) -> None:
        self._parsed = False
        self.logger = logger
        self.iso_name = iso_name
        self.iso_path = str(iso_path)
        self._sqfs: list[SquashFSImage] = []
        self._iso = ISOImage(
            name=self.iso_name,
            path=self.iso_path,
            size=Path(self.iso_path).stat().st_size,
            mount=ImageMounter(self.iso_name, self.iso_path, "iso"),
            packages=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 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(f"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(f"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(RPMDBPackages.get_package_info(pkg))
        self.logger.info(
            f"Collected {len(self._iso.packages)} RPM packages from {self.iso_path}/ALTLinux/RPMS.main"
        )

    def _process_squashfs(self):
        self.logger.info(f"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(f"Reading SquashFs image RPM packages")
            try:
                rpmdb = RPMDBPackages(
                    str(Path(sqfs.mount.path).joinpath("var/lib/rpm"))
                )
                sqfs.packages = rpmdb.packages_list
                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(
                f"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
    files: bool = False
    dumpjson: bool = False


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

    packages: dict[str, list[dict[str, Any]]]

    def __init__(
        self, iso: _StringOrPath, branch: str, type: str, config: Config
    ) -> None:
        if type not in ("regular", "sp"):
            raise ValueError(f"Invalid distribution type {type}")
        self.type = type
        self.path = Path(iso)
        self.name = self.path.name
        self.branch = branch
        self.config = config
        self.packages = {}
        self.result = {}

        self.logger = self.config.logger

        self.iso = ISO(iso_name=self.path.name, iso_path=self.path, logger=self.logger)

    def _send_request(
        self, url: str, params: dict, payload: dict, method: str = "GET", retry: int = 1
    ) -> requests.Response:
        r: 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:
                r = requests.request(
                    method,
                    url=url,
                    params=params,
                    json=payload,
                    timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READOUT_TIMEOUT),
                )
            except requests.Timeout:
                self.logger.debug(f"Request timed out")
            except requests.RequestException as e:
                self.logger.error(f"Request failed with {e}")
                raise APIRequestError(f"Request failed with {e}")
            if r.status_code in REQUEST_OK_CODES:
                self.logger.debug(
                    f"Request elapsed {r.elapsed.total_seconds():.3f} seconds"
                )
                return r
            timeout_ *= i + 1
            self.logger.debug(f"Retrying to send request after {timeout_} seconds")
            time.sleep(timeout_)
        self.logger.error(
            f"Request to {url} failed with code {r.status_code} after {retry} attempts"
        )
        raise ImageInspectorError(
            f"Request to {url} failed with code {r.status_code} after {retry} attempts"
        )

    def inspect_regular(self, image: str) -> None:
        payload = {"branch": self.branch, "packages": self.packages[image]}
        params = {}
        url = "/".join((self.config.url, RDB_INSPECT_REGULAR))
        r = self._send_request(
            url=url,
            params=params,
            payload=payload,
            method="POST",
            retry=REQUEST_RETRY_COUNT,
        )
        res = r.json()
        if self.config.dumpjson:
            fname = f"{self.name.replace(' ', '_')}_{image}.json"
            self.logger.info(f"Dump package inspection results to file {fname}")
            Utils.dump_to_json(res, fname)

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

        if res["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 {res['not_in_branch']} invalid packages"
        self.logger.info(
            f'{res["not_in_branch"]} not found in branch for {res["input_pakages"]} from \'{image}\''
        )
        if res["found_in_tasks"] != 0:
            self.result[image]["packages"]["in_tasks"] = res["packages_in_tasks"]
            if self.config.files:
                fname = f"{image}_{FILE_PKGS_IN_TASKS}"
                Utils.dump_to_json(res["packages_in_tasks"], fname)
                self.logger.info(
                    f"{res['found_in_tasks']} packages found in build tasks stored to '{fname}'"
                )

        if res["not_found_in_db"] != 0:
            self.result[image]["packages"]["not_in_db"] = res["packages_not_in_db"]
            if self.config.files:
                fname = f"{image}_{FILE_PKGS_NOT_IN_DB}"
                Utils.dump_to_json(res["packages_not_in_db"], fname)
                self.logger.info(
                    f"{res['not_found_in_db']} packages not found in database stored to '{fname}'"
                )

    def inspect_sp(self, image: str) -> None:
        payload = {"branch": self.branch, "packages": self.packages[image]}
        params = {}
        url = "/".join((self.config.url, RDB_INSPECT_SP))
        r = self._send_request(
            url=url,
            params=params,
            payload=payload,
            method="POST",
            retry=REQUEST_RETRY_COUNT,
        )
        res = r.json()
        if self.config.dumpjson:
            fname = f"{self.name.replace(' ', '_')}_{image}.json"
            self.logger.info(f"Dump package inspection API response to file {fname}")
            Utils.dump_to_json(res, fname)

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

        if res["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 {res['not_in_branch']} invalid packages"
        self.logger.info(
            f'{res["not_in_branch"]} packages not found in branch '
            f'for {res["input_pakages"]} packages from \'{image}\''
        )
        if res["found_in_tasks"] != 0:
            self.result[image]["packages"]["in_tasks"] = [
                r for r in res["packages"] if r["found_in"].startswith("task")
            ]
            if self.config.files:
                fname = f"{image}_{FILE_PKGS_IN_TASKS}"
                Utils.dump_to_json(self.result[image]["packages"]["in_tasks"], fname)
                self.logger.info(
                    f"{res['found_in_tasks']} packages found in build tasks stored to '{fname}'"
                )

        if res["not_found_in_db"] != 0:
            self.result[image]["packages"]["not_in_db"] = [
                r
                for r in res["packages"]
                if r["found_in"] in ("not found", "last branch")
            ]
            if self.config.files:
                fname = f"{image}_{FILE_PKGS_NOT_IN_DB}"
                Utils.dump_to_json(self.result[image]["packages"]["not_in_db"], fname)
                self.logger.info(
                    f"{res['not_found_in_db']} packages not found in database stored to '{fname}'"
                )

    def run(self) -> None:
        # 0. mount and parse ISO image
        self.iso.run()
        # 1. collect packages in JSON-like format
        def _pkg2dict(p: Package) -> dict[str, Any]:
            return {
                "pkg_arch": p.arch,
                "pkg_hash": str(p.hash),
                "pkg_name": p.name,
                "pkg_epoch": p.epoch,
                "pkg_version": p.version,
                "pkg_release": p.release,
                "pkg_disttag": p.disttag,
                "pkg_buildtime": p.buildtime,
            }

        if self.iso.iso.packages:
            self.packages["iso"] = []
            for p in self.iso.iso.packages:
                self.packages["iso"].append(_pkg2dict(p))
            self.logger.info(
                f"Found {len(self.iso.iso.packages)} RPM packages from ISO image"
            )
        for sqfs in self.iso.sqfs:
            if not sqfs.packages:
                continue
            self.packages[sqfs.name] = []
            for p in sqfs.packages:
                self.packages[sqfs.name].append(_pkg2dict(p))
            self.logger.info(
                f"Found {len(sqfs.packages)} RPM packages from '{sqfs.name}' SquashFS image"
            )
        # 2. dump packages to JSON
        if self.config.dumpjson:
            n = self.name.replace(" ", "_")
            with Path(f"packages_{n}.json").open("w") as f:
                json.dump(self.packages, f)
        # 3. check packages through API
        for image in ("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(
        "^((https|http):\/\/)?[a-zA-Z0-9\.\_\-]+(:[0-9]{2,5})?\/?$"  # type: ignore
    )
    if not url_match.search(url):
        raise argparse.ArgumentTypeError("Not a valid URL")
    return url


def get_args():
    parser = argparse.ArgumentParser(
        prog=NAME,
        description="Inspect RPM packages from ISO image with branch 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):
    base_loglevel = 30
    verbosity = min(verbosity, 2)
    loglevel = base_loglevel - (verbosity * 10)

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


def main():
    args = get_args()
    logger = setup_logging(args.verbosity)
    url = args.url.rstrip("/")
    cfg = Config(
        url=url,
        logger=logger,
        files=args.files,
        dumpjson=args.dumpjson,
    )
    inspector = ISOInspector(args.path, args.branch, args.type, cfg)
    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()
