#!/usr/bin/python3

"""The simplebackup utility script."""

from __future__ import annotations

import argparse
import glob
import os
import sys
import syslog
import tarfile
import threading
import time
import typing

from lib import common, constants, fs_operations, params_parsing, system_info, tar_operations, utils


def parse_args() -> argparse.Namespace:
    """
    Parse the command line arguments.

    Returns:
        argparse.Namespace: The parsed arguments.

    Raises:
        SystemExit: If the `-h`/`--help` or an invalid argument is passed.
    """
    # Initialize the argument parser to accept the backup and restore arguments
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="operation", required=True, help="The operation to perform")

    backup_parser = subparsers.add_parser("backup")
    backup_parser.add_argument(
        "-n",
        "--no_upload",
        dest="no_upload",
        action="store_true",
        default=False,
        help="Do not upload the archive to a server",
    )

    backup_parser.add_argument(
        "-c",
        "--check_consistency_delay",
        dest="check_consistency_delay",
        action="store",
        type=int,
        default=-1,  # -1 means that value from the config file should be used
        help=(
            "The delay in seconds before checking the consistency of the backup archive. "
            "If 0 is passed, the consistency check is disabled (default: 3)"
        ),
    )

    restore_parser = subparsers.add_parser("restore")
    restore_parser.add_argument("-f", "--file", dest="file", help="The path to the backup", type=str, required=True)

    # Policy of comparing backed up system information with the current one:
    # "full": all important parameters must be equal (default),
    # "disabled": ignore mismatched parameters
    restore_parser.add_argument(
        "-p",
        "--sys_info_compare_policy",
        dest="sys_info_compare_policy",
        action="store",
        default=utils.SysInfoComparePolicy.Full,
        choices=list(utils.SysInfoComparePolicy),
        help=argparse.SUPPRESS,  # Hide the help message to not reveal the option to the user
    )

    auto_backup_group = restore_parser.add_mutually_exclusive_group()
    auto_backup_group.add_argument(
        "-n",  # Couldn't find a better flag name
        "--no_backup",
        dest="no_backup",
        action="store_true",
        help="Do not create an automatic backup before restoring",
    )

    auto_backup_group.add_argument(
        "-c",
        "--check_backup_consistency_delay",
        dest="check_consistency_delay",  # It's convenient and OK to use because of non-overlapping parsers
        action="store",
        type=int,
        default=-1,  # `-1` means that value from the config file should be used
        help=(
            "The delay in seconds before checking the consistency of the backup archive. "
            "If 0 is passed, the consistency check is disabled (default: 3)"
        ),
    )

    return parser.parse_args()


def backup_procedure(backup_args: utils.ProcedureParams) -> None:
    """
    Perform the backup procedure.

    Args:
        backup_args: The parameters container with the following meaningful attributes:
          - `no_upload` (bool): Whether to not upload the archive to an FTP server.
          - `check_consistency_delay` (int): The delay before the backup consistency check.
          - `ret_code` (int): The return code of the the backup procedure.
    """  # The bullet list is misaligned intentionally as LSP only renders this style in a readable way
    # Check that backups directory exists and create it if it doesn't
    if not fs_operations.check_or_create_dir(constants.BACKUPS_DIR):
        backup_args.ret_code = utils.RetCodes.TmpDirError
        return

    # Parse the parameters from the `.ini` configuration file
    ret_code, parameters = params_parsing.get_parameters(
        no_upload=backup_args.no_upload,
        check_consistency_delay=backup_args.check_consistency_delay,
    )
    if not parameters:
        backup_args.ret_code = ret_code
        return

    if utils.exit_flag:
        backup_args.ret_code = utils.RetCodes.InterruptError
        return

    backup_args.ret_code = common.backup(parameters, common_script_timeout=parameters.common_script_timeout)

    if backup_args.ret_code == utils.RetCodes.Success:
        utils.send_msg(
            f"Backup is completed and saved to '{parameters.backup_path}'.",
            syslog.LOG_INFO,
            copy_to_stdout=True,
        )
    elif backup_args.ret_code == utils.RetCodes.BackupAreSameError:
        utils.send_msg(
            "Backup is not created, because files being backed up have not changed.",
            syslog.LOG_INFO,
            copy_to_stdout=True,
        )
        backup_args.ret_code = utils.RetCodes.Success


def backup_before_restore(backup_args: utils.ProcedureParams) -> None:
    """
    Perform the backup procedure before restoring.

    Args:
        backup_args: The parameters container with the following meaningful attributes:
          - `file` (str): The path to the backup to be restored.
          - `check_consistency_delay` (int): The delay before the backup consistency check.
          - `ret_code` (int): The return code of the backup procedure.
    """  # The bullet list is misaligned intentionally as LSP only renders this style in a readable way
    utils.send_msg("Creating an auto-backup before restoring...", syslog.LOG_INFO)  # To clarify what's going on

    # Parse the parameters from the `.ini` configuration file
    ret_code, parameters = params_parsing.get_parameters(
        no_upload=True,
        check_consistency_delay=backup_args.check_consistency_delay,
    )
    if not parameters:
        backup_args.ret_code = ret_code
        return

    # Extract the backuplist from the archive to temporary directory to create an auto-backup
    backuplist_path = tar_operations.extract_file_from_tar(
        backup_args.file,
        constants.BACKUPLIST_FILE,
        constants.EXTRACTED_TEMP_DIR,
    )
    if not backuplist_path:
        backup_args.ret_code = utils.RetCodes.ExtractTarArchiveError
        return

    # Override some auto-backup-specific parameters
    parameters.backuplist_path = backuplist_path
    parameters.backup_dir = constants.AUTO_BACKUPS_DIR
    parameters.prefix_for_backup_archive = (
        f"auto_{parameters.prefix_for_backup_archive}" if parameters.prefix_for_backup_archive else "auto"
    )
    parameters.use_ftp_settings_from_upagent = False  # To not even bother parsing its configuration
    parameters.no_upload = True

    # Make sure the auto-backup directory exists
    if not fs_operations.check_or_create_dir(parameters.backup_dir):
        backup_args.ret_code = utils.RetCodes.TmpDirError
        return

    if utils.exit_flag:
        backup_args.ret_code = utils.RetCodes.InterruptError
        return

    backup_args.ret_code = common.backup(
        parameters,
        common_script_timeout=parameters.common_script_timeout,
        skip_backups_comparing=True,  # We want to be explicit and always have a valid auto-backup
        delete_previous_backups=False,  # It's an administrator's responsibility to clean up auto-backups
    )

    if backup_args.ret_code == utils.RetCodes.Success:
        utils.send_msg(
            f"Automatic backup is completed and saved to '{parameters.backup_path}'.",
            syslog.LOG_INFO,
            copy_to_stdout=True,
        )


def enable_service(service_name: str) -> bool:
    """
    Enable a service unit.

    Args:
        service_name: The name of the service to enable.

    Returns:
        bool: `True` if the service is enabled, `False` otherwise.
    """
    cmd = f'systemctl enable "{service_name}"'
    timeout = 10

    out, err, ret_code, exit_reason = utils.externcmd(
        cmd,
        timeout,
        ignore_global_exit_flag=True,  # Ignoring for `enable_services_from_file()`'s loop
    )
    if ret_code != 0:
        utils.send_msg_by_exit_reason(cmd, exit_reason, ret_code, timeout, out, err)
        utils.send_msg(f"The service '{service_name}' is not enabled.", syslog.LOG_ERR)
        return False

    utils.send_msg(f"The service '{service_name}' is enabled successfully.", syslog.LOG_INFO)
    return True


def enable_services_from_file(path_to_file: str) -> bool:
    """
    Enable services that are marked as enabled in the target services statuses file.

    Args:
        path_to_file: The path to the file with the list of services to enable.

    Returns:
        bool: `True` if all services are enabled, `False` otherwise.
    """
    if not fs_operations.check_file(path_to_file):
        return False

    services_status = fs_operations.read_json_file(path_to_file)  # Really `list[ServiceStatus]` in dict representations
    if services_status is None:
        utils.send_msg(f"Failed to parse services statuses from the file '{path_to_file}'.", syslog.LOG_ERR)
        return False

    were_enabled = True
    for service_status in services_status:
        if not service_status["state"]["enabled"]:
            continue

        service_name = service_status["unit_file"]
        if common.get_enabled_state_for_service(service_name) == "enabled":
            continue

        if not enable_service(service_name):
            were_enabled = False

    fs_operations.sync_to_disk()

    return were_enabled


def update_files_integrity() -> bool:
    """
    Recalculate system files checksums with the `/opt/VPNagent/bin/links_verify update` command.

    Applicable only to S-Terra Gates.

    Returns:
        bool: `True` if the command completed successfully (returned `0`), `False` otherwise.
    """
    utils.send_msg("Updating files integrity (may take up to several minutes)...", syslog.LOG_INFO)
    command = "/opt/VPNagent/bin/links_verify update"

    # Everything is printed to STDERR for some reason:
    # https://gitlab.s-terra.com/vpnagent/agent/-/blob/master/agent-linuxpkg/links_verify/tool/src/opt/VPNagent/bin/links_verify
    _, stderr, ret_code, _ = utils.externcmd(
        command,
        timeout=60 * 3,  # 3 minutes for super-slow machines (e.g. 2 minutes are experimentally proven)
        ignore_global_exit_flag=True,
        no_stdout=True,
    )

    if ret_code != 0:
        utils.send_msg(
            f"Failed to update files integrity with the command '{command}'.",
            syslog.LOG_ERR,
            details_msg=stderr,
        )
        return False

    fs_operations.sync_to_disk()
    utils.send_msg("Files integrity is updated successfully.", syslog.LOG_INFO)
    return True


def check_files_integrity() -> bool:
    """
    Check system files integrity with the `/opt/VPNagent/bin/links_verify check` command.

    Note: `links_verify check` is NOT faster than `links_verify update`, both are relatively slow and take
    roughly the same amount of time.

    Applicable only to S-Terra Gates.

    Returns:
        bool: `True` if the command completed successfully (returned `0`), `False` otherwise.
    """
    utils.send_msg("Checking files integrity (may take up to several minutes)...", syslog.LOG_INFO)
    command = "/opt/VPNagent/bin/links_verify check"

    # Everything is printed to STDERR for some reason:
    # https://gitlab.s-terra.com/vpnagent/agent/-/blob/master/agent-linuxpkg/links_verify/tool/src/opt/VPNagent/bin/links_verify
    _, stderr, ret_code, _ = utils.externcmd(command, timeout=60, no_stdout=True)

    if ret_code != 0:
        if stderr.endswith("Links verification has failed\n"):
            utils.send_msg(
                "Files integrity verification has failed. The OS may not boot after restart.",
                syslog.LOG_ERR,
                details_msg=stderr,
                copy_to_stdout=True,
            )
            return False

        utils.send_msg(
            f"Failed to check files integrity with command '{command}'.",
            syslog.LOG_ERR,
            details_msg=stderr,
        )
        return False

    return True


def _compare_systems_info(
    context_stack: list[str],
    backup_value: int | str | dict[str, typing.Any] | list[typing.Any],
    current_value: int | str | dict[str, typing.Any] | list[typing.Any],
    sys_info_compare_policy: utils.SysInfoComparePolicy,
) -> bool:
    """
    Recursively compare system information parameters.

    Args:
        context_stack: The path to the current parameter as a list of keys.
        backup_value: The value from the backed up system information.
        current_value: The value from the current system information.
        sys_info_compare_policy: System information compare policy.

    Returns:
        bool: `True` if the parameters are equal, `False` otherwise.
    """
    # List of the parameters paths to ignore when comparing
    ignore_list: list[list[str]] = [["sterra_gate_info", "build_number"]]

    are_values_equal = True

    # If both values are dictionaries, recursively check each key-value pair
    if isinstance(backup_value, dict) and isinstance(current_value, dict):
        for current_param_key, current_param_value in current_value.items():
            full_stack = [*context_stack, current_param_key]

            if current_param_key not in backup_value:
                param_path = ".".join(full_stack)
                utils.send_msg(
                    f"The parameter '{param_path}' is not found in the system information from the backup.",
                    syslog.LOG_ERR,
                )

                are_values_equal = False
                continue

            # Recursively check parameters equality, continue even if the difference is found.
            # It is necessary to check all parameters
            are_values_equal &= _compare_systems_info(
                full_stack,
                backup_value[current_param_key],
                current_param_value,
                sys_info_compare_policy,
            )

        return are_values_equal

    # If types are different or values are different
    if backup_value != current_value:
        if sys_info_compare_policy == utils.SysInfoComparePolicy.Disabled or context_stack in ignore_list:
            log_level = syslog.LOG_WARNING
        else:
            log_level = syslog.LOG_ERR
            are_values_equal = False

        param_path = ".".join(context_stack)
        utils.send_msg(
            (
                f"The parameter '{param_path}' value in the system information from the backup "
                "doesn't match the value in the current system information."
            ),
            log_level,
            details_msg=(
                f"The parameter value from the backup: '{backup_value}'\nThe current parameter value: '{current_value}'"
            ),
        )

    return are_values_equal


def check_backup_compliance(backup_path: str, sys_info_compare_policy: utils.SysInfoComparePolicy) -> bool:
    """
    Check whether the backup is compliant with the current system (i.e. can be restored).

    Args:
        backup_path: The path to the backup.
        sys_info_compare_policy: System information comparing policy.

    Returns:
        bool: `True` if the backup is compliant with the current system, `False` otherwise.
    """
    ret_status, backup_info = system_info.get_system_info_from_archive(backup_path, sys_info_compare_policy)
    if not ret_status:  # System information can't be retrieved
        return False

    if not backup_info:  # System information was not found in the archive (e.g. backup was made with an older version)
        return sys_info_compare_policy == utils.SysInfoComparePolicy.Disabled

    current_info = system_info.get_system_info()
    if not current_info:
        utils.send_msg("Failed to get the current system information.", syslog.LOG_ERR)
        return False

    return _compare_systems_info([], backup_info, current_info, sys_info_compare_policy)


def count_tar_extraction_impact(archive_path: str) -> int:
    """
    Count the size in bytes additionally written to disk during the archive extraction.

    The size could be negative (e.g. if archived files are smaller than corresponding local ones).

    Args:
        archive_path: Path to the archive.

    Returns:
        int: Additional bytes written to disk during the archive extraction amount.
    """
    try:
        with tarfile.open(archive_path) as tar:
            tar_members = tar.getmembers()
    except Exception as ex:
        utils.send_msg(
            f"Unexpected error occurred while reading the archive '{archive_path}':",
            syslog.LOG_ERR,
            details_msg=str(ex),
        )
        return -1

    extra_size = 0
    for member in tar_members:
        member_size = member.size
        if member.isdir():
            # `4096` is a usual block (and new directory) size.
            # We could use `os.statvfs()` to get the real block size, but there is no point to do so since directory
            # size also depends on its contents, and the prediction still won't be 100% accurate
            member_size = 4096
        elif member.issym() or member.islnk():
            # Symlinks have size of 0 stored in the archive, but actually their real sizes match the size
            # of the linked path string, and we could estimate it via `len()`
            member_size = len(member.linkname)

        local_path = os.path.join("/", member.name)
        if not fs_operations.is_path_existent(local_path):
            extra_size += member_size
            continue

        # Generally exceptions shouldn't happen, but it's better to ensure (actually with no somewhat noticeable
        # slowdown even in Python prior to 3.11) for this amount of external calls
        try:
            extra_size += member_size - os.lstat(local_path).st_size
        except OSError as ex:
            utils.send_msg(
                f"Unexpected error occurred while getting the size of '{local_path}':",
                syslog.LOG_WARNING,
                details_msg=str(ex),
            )

    return extra_size


def cleanup_before_restore(backup_path: str) -> bool:
    """
    Delete the paths meant to be deleted (marked with `--` in the archived `backuplist`) before restoring.

    Args:
        backup_path: The path to the backup.

    Returns:
        bool: `True` if the cleanup was successful, `False` otherwise.
    """
    backuplist_path = tar_operations.extract_file_from_tar(
        backup_path,
        constants.BACKUPLIST_FILE,
        constants.EXTRACTED_TEMP_DIR,
    )
    if not backuplist_path:
        return False

    raw_patterns_list = utils.get_patterns_from_backuplist(backuplist_path)
    fs_operations.delete_path(backuplist_path)

    if not raw_patterns_list:
        if raw_patterns_list is not None:  # Should never happen, but just in case
            utils.send_msg(
                (
                    f"Can't clean up before restoring '{backup_path}: "
                    f"no patterns found in the archived '{constants.BACKUPLIST_FILE}'."
                ),
                syslog.LOG_ERR,
            )

        return False

    for raw_pattern in raw_patterns_list:
        if not raw_pattern.startswith("--"):
            continue

        pattern = raw_pattern[2:].lstrip()
        for match in glob.iglob(pattern):
            utils.send_msg(f"Deleting '{match}' before restoring...", syslog.LOG_INFO)

            if not fs_operations.delete_path(match):
                return False

    return True


@common.common_script_decorator("restore")
def restore(
    backup_path: str,
    collect_extra_info: str,
    *,
    common_script_timeout: int = 0,  # noqa: ARG001 as necessary for decorator
    common_script_path: str = constants.COMMON_SCRIPT_FILE,  # noqa: ARG001 as necessary for decorator
) -> utils.RetCodes:
    """
    Restore the files and directories from the archive.

    Args:
        backup_path: The path to the backup.
        collect_extra_info: Whether to collect services statuses.
        common_script_timeout: The common script execution timeout (used in decorator).
        common_script_path: The path to the common script (used in decorator).

    Returns:
        RetCodes: The return code.
    """
    # Verify that backed up services list matches current system's one to not crash trying to enable absent services
    if collect_extra_info == "ALL":
        are_services_present = common.check_services_presence(backup_path)

        if are_services_present is None:
            utils.send_msg("Failed to check services presence.", syslog.LOG_ERR)
            return utils.RetCodes.ExtraInfoError

        if not are_services_present:
            utils.send_msg("Backed up services list does not match current system's one.", syslog.LOG_ERR)
            return utils.RetCodes.ExtraInfoError

    # Ensure that there is enough disk space to extract the archive
    free_space = fs_operations.get_disk_free_space("/")
    if free_space < 0:
        return utils.RetCodes.ExtractTarArchiveError

    # TODO: Учесть распаковку на разные диски (разделы диска, устройства).
    # Сейчас необходимое свободное место для распаковки архива рассчитывается для диска, где находится корень
    # файловой системы. Однако файлы из архива могут расположиться на других смонтированных дисках, и оценка
    # свободного места относительно основного диска будет неверной, не позволяя распаковать архив
    extraction_impact = count_tar_extraction_impact(backup_path)
    if free_space < extraction_impact:
        utils.send_msg(
            (
                f"Not enough disk space to extract the archive '{backup_path}'. "
                f"Lacking approximately extra {utils.bytes_to_human_readable_str(extraction_impact - free_space)}."
            ),
            syslog.LOG_ERR,
        )
        return utils.RetCodes.ExtractTarArchiveError

    # Extract the archive itself
    # TODO: Refactor to a more general function
    commands_timeout = 30
    service_name = "vpngate.service"

    is_vpngate_restart_needed = False  # `False` for non-S-Terra Gates
    if system_info.is_sterra_gate():
        command = f'systemctl is-active "{service_name}"'

        # Exits with `0` if the service is active. Currently the best way to check if the service is running
        _, _, ret_code, _ = utils.externcmd(command, commands_timeout)
        is_vpngate_restart_needed = ret_code == 0

    if is_vpngate_restart_needed:
        command = f'systemctl stop "{service_name}"'  # Exits with `0` if the service is not running

        utils.send_msg(f"Stopping '{service_name}' to clean up before restoring...", syslog.LOG_INFO)
        stdout, stderr, ret_code, exit_reason = utils.externcmd(command, commands_timeout)
        if ret_code != 0:
            utils.send_msg_by_exit_reason(command, exit_reason, ret_code, commands_timeout, stdout, stderr)
            utils.send_msg(f"Failed to stop '{service_name}' to clean up before restoring.", syslog.LOG_ERR)
            return utils.RetCodes.ServiceRestartError

    if not cleanup_before_restore(backup_path):
        return utils.RetCodes.CleanupBeforeRestoreError

    if not tar_operations.extract_tar_archive(backup_path, "/"):
        return utils.RetCodes.ExtractTarArchiveError

    # Update files integrity to not brick the machine.
    # If the update fails, we can check if hashes are already correct instead of instantly returning an error
    # (which would be false positive as files are already fully restored and hashes are actual).
    # Running check before updating is pointless because they are both slow and take the same amount of time
    if system_info.is_sterra_gate() and not (update_files_integrity() or check_files_integrity()):
        utils.send_msg("Files integrity verification failed", syslog.LOG_ERR)
        return utils.RetCodes.FilesIntegrityError

    if is_vpngate_restart_needed:
        command = f'systemctl start "{service_name}"'

        utils.send_msg(f"Starting '{service_name}' after restoring...", syslog.LOG_INFO)
        stdout, stderr, ret_code, exit_reason = utils.externcmd(
            command,
            commands_timeout,
            ignore_global_exit_flag=True,
        )
        if ret_code != 0:
            utils.send_msg_by_exit_reason(command, exit_reason, ret_code, commands_timeout, stdout, stderr)
            utils.send_msg(f"Failed to start '{service_name}' after restoring.", syslog.LOG_ERR)
            return utils.RetCodes.ServiceRestartError

    if collect_extra_info == "ALL" and not enable_services_from_file(constants.SERVICES_STATUS_FILE):
        utils.send_msg("Can't restore services state", syslog.LOG_ERR)
        return utils.RetCodes.ExtraInfoError

    return utils.RetCodes.Success


@common.lock_decorator
def restore_procedure(restore_args: utils.ProcedureParams) -> None:
    """
    Perform the restore procedure.

    Args:
        restore_args: The parameters container with the following meaningful attributes:
          - `file` (str): The path to the backup to restore.
          - `check_consistency_delay` (int): The delay before the auto-backup consistency check.
          - `no_auto_backup` (bool): Whether to not create an auto-backup before restoring.
          - `ret_code` (int): The return code of the restore procedure.
    """  # The bullet list is misaligned intentionally as LSP only renders this style in a readable way
    # Verify that the backup exists, has read permissions, and is not empty
    if not fs_operations.check_file(restore_args.file):
        restore_args.ret_code = utils.RetCodes.BackupValidateError
        return

    # Verify that the backup is a valid tar file
    if not tarfile.is_tarfile(restore_args.file):
        utils.send_msg(f"'{restore_args.file}' is not a valid tar archive.", syslog.LOG_ERR, copy_to_stdout=True)
        restore_args.ret_code = utils.RetCodes.ExtractTarArchiveError
        return

    # Verify that the backup is compliant with the system
    if not check_backup_compliance(restore_args.file, restore_args.sys_info_compare_policy):
        utils.send_msg("Backup is not compliant with the system.", syslog.LOG_ERR)
        restore_args.ret_code = utils.RetCodes.ComplianceError
        return

    # Create an auto-backup if needed
    if not restore_args.no_auto_backup:
        if utils.exit_flag:
            restore_args.ret_code = utils.RetCodes.InterruptError
            return

        backup_before_restore(restore_args)

        if restore_args.ret_code != utils.RetCodes.Success:
            utils.send_msg("Automatic backup has failed.", syslog.LOG_ERR)
            return

    # Extract the configuration file from the archive to read `COMMON_SCRIPT_TIMEOUT` and `COLLECT_EXTRA_INFO` from it
    config_file_path = tar_operations.extract_file_from_tar(
        restore_args.file,
        constants.CONFIG_INI_FILE,
        constants.EXTRACTED_TEMP_DIR,
    )
    if not config_file_path:
        restore_args.ret_code = utils.RetCodes.ExtractTarArchiveError
        return

    # Get `COMMON_SCRIPT_TIMEOUT`. If it is not set, use the default value
    got_timeout, scripts_timeout_value = params_parsing.get_param_by_name(
        "COMMON_SCRIPT_TIMEOUT",
        int,
        config_file_path,
    )
    if got_timeout is False:
        restore_args.ret_code = utils.RetCodes.ParseProgParamError
        return

    # Get `COLLECT_EXTRA_INFO`. If it is not set, use the default value
    got_extra_info, collect_extra_info = params_parsing.get_param_by_name(
        "COLLECT_EXTRA_INFO",
        str,
        config_file_path,
    )
    if got_extra_info is False:
        restore_args.ret_code = utils.RetCodes.ParseProgParamError
        return

    # Extract the common script from the archive to run it instead of the local one
    common_script_path = tar_operations.extract_file_from_tar(
        restore_args.file,
        constants.COMMON_SCRIPT_FILE,
        constants.EXTRACTED_TEMP_DIR,
    )
    if not common_script_path:
        restore_args.ret_code = utils.RetCodes.ExtractTarArchiveError
        return

    if utils.exit_flag:
        restore_args.ret_code = utils.RetCodes.InterruptError
        return

    restore_args.ret_code = restore(
        restore_args.file,
        typing.cast("str", collect_extra_info),  # `str` is guaranteed there
        common_script_timeout=typing.cast("int", scripts_timeout_value),  # `int` is guaranteed there
        common_script_path=common_script_path,
    )

    if restore_args.ret_code == utils.RetCodes.Success:
        utils.send_msg("Restore is completed.", syslog.LOG_INFO, copy_to_stdout=True)


# TODO: Избавление от `threading`.
# Использование `threading` таким способом неуместно, стоит от него избавиться.
# Для этого нужно разобраться с TODO в `FTPClient`
def main() -> utils.RetCodes:
    """
    Execute the simplebackup utility.

    Returns:
        RetCodes: The return code of the simplebackup utility.
    """
    parsed_args = parse_args()

    # Set the identification for syslog.
    # Located after `parse_args()` to not confuse users with a single `"... starting..."` message on invalid arguments
    syslog_tag = constants.SYSLOG_TAG
    syslog.openlog(facility=syslog.LOG_LOCAL7, ident=syslog_tag)
    utils.send_msg(f"The {syslog_tag} utility is starting...", syslog.LOG_INFO)

    thread_args = utils.ProcedureParams(check_consistency_delay=parsed_args.check_consistency_delay)

    if parsed_args.operation == "backup":
        thread_args.no_upload = parsed_args.no_upload
        worker_thread = threading.Thread(target=backup_procedure, args=(thread_args,), daemon=True)

    else:  # `parsed_args.operation == "restore"`
        thread_args.file = os.path.abspath(parsed_args.file)
        thread_args.no_auto_backup = parsed_args.no_backup
        thread_args.sys_info_compare_policy = utils.SysInfoComparePolicy(parsed_args.sys_info_compare_policy)
        worker_thread = threading.Thread(target=restore_procedure, args=(thread_args,), daemon=True)

    worker_thread.start()
    while worker_thread.is_alive() and not utils.exit_flag:
        time.sleep(1)

    mode_to_print = parsed_args.operation.capitalize()
    if utils.exit_flag:
        if not utils.wait_for_threads(
            60 * 3 + 10,  # 3 minutes to manage restoring on super-slow machines + extra 10 seconds
        ):
            return utils.RetCodes.ShutdownError

        if thread_args.ret_code == utils.RetCodes.InterruptError:
            utils.send_msg(f"{mode_to_print} process was interrupted.", syslog.LOG_INFO, copy_to_stdout=True)
            return thread_args.ret_code

    if thread_args.ret_code != utils.RetCodes.Success:
        utils.send_msg(f"{mode_to_print} has failed.", syslog.LOG_ERR, copy_to_stdout=True)

    return thread_args.ret_code


if __name__ == "__main__":
    utils.setup_signal_handlers()

    sys.exit(main())
