#!/usr/bin/env python3
#     The Certora Prover
#     Copyright (C) 2025  Certora 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 of the License.
#
#     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 <https://www.gnu.org/licenses/>.

import dataclasses
import json
import multiprocessing
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Any, List, Dict, Tuple, Set, Union
import logging
from types import SimpleNamespace
import tarfile
import csv
import time
import requests
import urllib3.util
from strenum import StrEnum
from rich.console import Console


scripts_dir_path = Path(__file__).parent.resolve()  # containing directory
sys.path.insert(0, str(scripts_dir_path))

from CertoraProver.certoraContextValidator import KEY_ENV_VAR
from Mutate import mutateConstants as MConstants
from Shared import certoraUtils as Util
from Shared.certoraLogging import LoggingManager
from certoraRun import run_certora
from Shared.proverCommon import CertoraRunResult, CertoraFoundViolations
from certoraSorobanProver import run_soroban_prover
from Shared import certoraValidateFuncs as Vf
from CertoraProver.Compiler.CompilerCollectorFactory import get_relevant_compiler
from Mutate import mutateUtil as MutUtil
from Mutate import mutateAttributes as MutAttrs
import CertoraProver.certoraContextAttributes as Attrs
from CertoraProver.certoraContextClass import CertoraContext
from rustMutator import run_universal_mutator
from CertoraProver import certoraContextValidator as Cv

class RunTimedout(Exception):
    pass


DEFAULT_NUM_MUTANTS = 5
ENV_FREE_CHECK = 'envfreeFuncsStaticCheck'

mutation_logger = logging.getLogger("mutation")


"""
Class definitions section start
"""


class MutationTestRuleStatus(StrEnum):
    SUCCESS = "SUCCESS"
    FAIL = "FAIL"
    TIMEOUT = "TIMEOUT"
    SANITY_FAIL = "SANITY_FAIL"
    UNKNOWN = "UNKNOWN"


@dataclass
class RuleResult:
    """
    This class represents the verification results of a mutant on a specific rule.

    name - the name of the rule tested
    status - the status of the rule, a string from MutationTestRuleStatus
    """
    name: str
    status: str

    def to_json(self) -> dict:
        return {
            "name": self.name,
            "status": self.status
        }


@dataclass
class Mutant:
    """
    A class that represents a mutant. It can be one of three types:
    1. The original, unmutated code
    2. A random mutation generated by Gambit
    3. A manual mutation carefully crafted by hand

    Example:
    {
        "filename": "gambit_out/mutants/1/C.sol",
        "original_filename": "C.sol",
        "directory": "gambit_out/mutants/1",
        "id": "1",
        "diff": "--- original\n+++ mutant\n@@ -1,5 +1,6 ...",
        "description": "DeleteExpressionMutation"
    }
    """
    filename: str
    original_filename: str
    directory: str
    id: str
    diff: str
    description: str
    name: str = ""

    def __post_init__(self) -> None:
        # Set the 'name' attribute based on the 'filename'. Intended to be used by Gambit mutants
        if not self.name:
            self.name = f"{self.id}_{Path(self.filename).stem}"

    def __str__(self) -> str:
        return self.name

    def apply_patch(self, file_path_to_mutate: Path) -> None:
        """
        For creating mutant file from patch we do the following:
            * Find the relative path from the git root to CWD
            * get the last commit of the file we are going to mutate and save it in applied_mutants
            * apply the patch file on the last commit file stored in applied_mutants
        :param file_path_to_mutate: path to target mutation file in applied_mutants
        :return:
        """
        try:
            relative_path_from_git_root = subprocess.check_output(['git', 'rev-parse',
                                                                   '--show-prefix']).decode().strip()
        except Exception:
            raise Util.CertoraUserInputError(f"Cannot find git repository cannot apply patch {self.filename}"
                                             f" for {self.original_filename}")

        git_path_to_orig = Path(relative_path_from_git_root) / self.original_filename
        last_commit_contents = subprocess.check_output(['git', 'show', f'HEAD:{git_path_to_orig}']).decode()
        # Save the contents to the new file
        with file_path_to_mutate.open('w') as f:
            f.write(last_commit_contents)
        try:
            patch_command = ['patch', str(file_path_to_mutate), self.filename]
            subprocess.run(patch_command, check=True, text=True, capture_output=True)
        except Exception as e:
            raise Util.CertoraUserInputError(f"failed to run {' '.join(patch_command)}", e)

@dataclass
class MutantJob:
    """
    A class that represents a mutation verification job sent to the cloud. It does not include verification results.

    Example:
    {
        "gambit_mutant": {
            "filename": "gambit_out/mutants/1/C.sol",
            "original_filename": "C.sol",
            "directory": "gambit_out/mutants/1",
            "id": "1",
            "diff": "--- original\n+++ mutant\n@@ -1,5 +1,6 ...",
            "description": "DeleteExpressionMutation"
        },
        "link": "https://vaas-stg.certora.com/jobStatus/85695/d7b565e07bb5408bbfd1680aa61f0eb8?anonymousKey=...",
        "success": true,
        "run_directory": ".certora_internal/24_01_29_19_04_42_864/.certora_sources",
        "rule_report_link": "https://vaas-stg.certora.com/output/85695/d7b565e07bb5408bbfd1680a?anonymousKey=..."
    }
    """
    gambit_mutant: Mutant
    link: Optional[str]
    success: bool
    run_directory: Optional[str]
    rule_report_link: Optional[str] = None

    def __post_init__(self) -> None:
        if isinstance(self.gambit_mutant, dict):
            self.gambit_mutant = Mutant(**self.gambit_mutant)

    def __str__(self) -> str:
        return self.gambit_mutant.__str__()


@dataclass
class TestJobs:
    """
    A class that holds the status of all the mutation test's jobs as fetched from the server.
    It has two components:
    1. A link to the run of the original code without any mutations.
    2. A list of verification jobs for the mutations.

    Note: The jobs may not be completed yet. This class does not hold any verification results.

    Example:
    {
       "original": "https://vaas-stg.certora.com/output/85695/ee248ffd4f1a4b8e82dd90915f995a2e?anonymousKey=...",
       "mutants": [
          {
             "gambit_mutant":{
                "filename": "gambit_out/mutants/1/C.sol",
                "original_filename": "C.sol",
                "directory": "gambit_out/mutants/1",
                "id": "1",
                "diff": "--- original\n+++ mutant\n@@ -1,5 +1,6 ...",
                "description": "DeleteExpressionMutation"
             },
             "link": "https://vaas-stg.certora.com/jobStatus/85695/d7b565e07bb5408bbfd1680aa61f0eb8?anonymousKey=...",
             "success": true,
             "run_directory": ".certora_internal/24_01_29_19_04_42_864/.certora_sources",
             "rule_report_link": "https://vaas-stg.certora.com/output/85695/d7b565e07bb5408bbfd1680a?anonymousKey=..."
          },
          ...
       ]
    }
    """
    original: str
    mutants: List[MutantJob]

    def __post_init__(self) -> None:
        self.mutants = [MutantJob(**mutant) for mutant in self.mutants if isinstance(mutant, dict)]


@dataclass
class MutantJobWithResults:
    """
    A class that holds a mutant verification job with its results.
    It has two components:
    1. A link to the run of the original code without any mutations.
    2. A list of verification jobs for the mutations.

    Note: The jobs may not be completed yet. This class does not hold any verification results.

    Example:
    {
        "mutant_job": {
             "gambit_mutant": {
                "filename": "gambit_out/mutants/1/C.sol",
                "original_filename": "C.sol",
                "directory": "gambit_out/mutants/1",
                "id": "1",
                "diff": "--- original\n+++ mutant\n@@ -1,5 +1,6 ...",
                "description": "DeleteExpressionMutation"
             },
             "link": "https://vaas-stg.certora.com/jobStatus/85695/d7b565e07bb5408bbfd1680aa61f0eb8?anonymousKey=...",
             "success": true,
             "run_directory": ".certora_internal/24_01_29_19_04_42_864/.certora_sources",
             "rule_report_link": "https://vaas-stg.certora.com/output/85695/d7b565e07bb5408bbfd1680a?anonymousKey=..."
        },
        "rule_results": [
            {
                "name": 'envfreeFuncsStaticCheck',
                "status": "SUCCESS"
            },
            ...
        ]
    }

    """

    mutant_job: MutantJob
    rule_results: List[RuleResult]

    # These are inferred from rule_results
    rules_with_result: List[RuleResult] = dataclasses.field(init=False)
    SANITY_FAIL: List[RuleResult] = dataclasses.field(init=False)
    UNKNOWN: List[RuleResult] = dataclasses.field(init=False)
    TIMEOUT: List[RuleResult] = dataclasses.field(init=False)

    def all_rule_names(self) -> List[str]:
        return [rule.name for rule in self.rule_results]

    def __validate_unique_rule_names(self) -> None:
        # Check for duplicates
        rule_names: Set[str] = set()
        for rule in self.rule_results:
            if rule.name in rule_names:
                raise RuntimeError(f"Found rule {rule.name} twice for mutant {self}. Malformed server response")
            rule_names.add(rule.name)

    def __post_init__(self) -> None:
        self.__validate_unique_rule_names()
        self.__populate_rules()

    def __populate_rules(self) -> None:
        self.rules_with_result = \
            [r for r in self.rule_results if r.status in
             {MutationTestRuleStatus.SUCCESS.value, MutationTestRuleStatus.FAIL.value}]
        self.SANITY_FAIL = [r for r in self.rule_results if r.status == MutationTestRuleStatus.SANITY_FAIL.value]
        self.UNKNOWN = [r for r in self.rule_results if r.status == MutationTestRuleStatus.UNKNOWN.value]
        self.TIMEOUT = [r for r in self.rule_results if r.status == MutationTestRuleStatus.TIMEOUT.value]

    def get_rule_status(self, rule_name: str) -> str:

        rule_status = next((rule.status for rule in self.rule_results if rule.name == rule_name), None)
        if not rule_status:
            rule_status = next((rule.status for rule in self.SANITY_FAIL if rule.name == rule_name), None)

        if not rule_status:
            return MConstants.DEFAULT_CSV_JOB_STATUS

        if rule_status == MutationTestRuleStatus.SUCCESS.value:
            return MConstants.UNCAUGHT
        if rule_status == MutationTestRuleStatus.FAIL.value:
            return MConstants.CAUGHT
        return rule_status

    def __str__(self) -> str:
        return f"{self.mutant_job.gambit_mutant} {self.mutant_job.link}"

    def to_report_json(self) -> dict:
        """
        Returns a dict representation that is JSON serializable of this class meant for the web report

        Example:
        {
          "description": "mutations/EBTCToken/EBTCToken_3.sol",
          "diff": "274c274,275\n<         require(cachedBalance >= amount...",
          "link": "https://prover.certora.com/jobStatus/93493/2ecb10...f?anonymousKey=5b2b0...",
          "name": "m1_EBTCToken_3",
          "id": "m1",
          "rules": [
            {
              "name": "mingIntegrity",
              "status": "SUCCESS"
            },
            ...
          ],
          "SANITY_FAIL": [],
          "UNKNOWN": [],
          "TIMEOUT": []
        }
        """
        data = {
            'description': self.mutant_job.gambit_mutant.description,
            'diff': self.mutant_job.gambit_mutant.diff,
            'link': self.mutant_job.link or "",
            'name': str(self.mutant_job.gambit_mutant),
            'id': self.mutant_job.gambit_mutant.id,
            'rules': [rule.__dict__ for rule in self.rules_with_result],
            'SANITY_FAIL': [rule.__dict__ for rule in self.SANITY_FAIL],
            'UNKNOWN': [rule.__dict__ for rule in self.UNKNOWN],
            'TIMEOUT': [rule.__dict__ for rule in self.TIMEOUT]
        }
        return data


"""
Class definitions section end
"""


def find_cwd(root_dir: Path) -> Path:
    """
    find the file .cwd under root_dir. checks that there is at most one such file under root_dir. If the file .cwd is
    not found it means root_dir is also the cwd (as it is in most cases)

    :param root_dir:
    :return: the directory of .cwd
    """
    results = []

    for root, _, files in os.walk(root_dir):
        if Util.CWD_FILE in files:
            results.append(root)
    if len(results) == 0:
        return root_dir
    if len(results) > 1:
        raise RuntimeError(f"found multiple {Util.CWD_FILE} files under {root_dir}: {results}")
    return Path(results[0])


def print_separator(txt: str) -> None:
    print(f"\n{'*' * 20} {txt} {'*' * 20}\n")


def wait_for_job(report_url: str) -> bool:
    attempts = 50
    sleep_time_in_sec = 10
    job_status_url = report_url.replace('/output/', '/jobData/')

    for _ in range(attempts):
        try:
            resp = requests.get(job_status_url)
        except requests.exceptions.MissingSchema:
            return False
        except requests.RequestException:
            return False

        if resp.status_code == 200:
            if resp.json()['jobStatus'] == 'SUCCEEDED':
                return True
            if resp.json()['jobStatus'] == 'FAILED':
                return False
        else:
            return False
        time.sleep(sleep_time_in_sec)
    return False


def get_conf_from_certora_metadata(certora_sources: Path) -> CertoraContext:
    metadata_file = certora_sources / ".certora_metadata.json"
    if metadata_file.exists():
        with metadata_file.open() as orig_run_conf:
            metadata = json.load(orig_run_conf)
            if MConstants.CONF in metadata:
                return CertoraContext(**metadata[MConstants.CONF])
            else:
                raise Util.CertoraUserInputError(f"{metadata_file} does not have the prover conf entry. Exiting.")
    else:
        raise RuntimeError(f"Could not find .certora_metadata.json in {certora_sources}. "
                           "Try certoraMutate with --conf.")


def get_diff(original: Path, mutant: Path) -> str:
    test_result = subprocess.run(["diff", "--help"], capture_output=True, text=True)
    if test_result.returncode:
        mutation_logger.warning("Unable to get diff for manual mutations. Install 'diff' and try again to "
                                "see more detailed information")
        return ""
    result = subprocess.run(["diff", str(original), str(mutant)], capture_output=True, text=True)
    if result.stdout is None:
        logging.warning(f"Could not get the diff with the mutated file {mutant}")
        logging.debug(f"original file: {original}, diff results: {result}")
    return result.stdout


def get_gambit_exec() -> str:
    exec = Util.get_package_resource(Util.CERTORA_BINS / MConstants.GAMBIT)
    # try executing it
    try:
        rc = subprocess.run([exec, "--help"], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        if rc.returncode == 0:
            return str(exec)
        else:
            mutation_logger.info(f"Failed to execute {exec}")
            stderr_lines = rc.stderr.splitlines()
            raise Util.CertoraUserInputError(f"Failed to execute {exec} \n\n{stderr_lines}")
    except Exception:
        # could not run the specialized name, just run gambit
        return MConstants.GAMBIT


def valid_link(link: str) -> bool:
    """
    Returns true if the provided link string is either a valid URL or a valid directory path
    """
    return Vf.Util.is_valid_url(urllib3.util.parse_url(link)) or validate_dir(link)


def validate_dir(url: str) -> bool:
    try:
        return Path(url).is_dir()
    except Exception:
        return False


def valid_message(msg: str) -> None:
    if msg:
        pattern = re.compile(r"^[0-9a-zA-Z_=, ':\.\-\/]+$")
        if not re.match(pattern, msg):
            raise Util.CertoraUserInputError("The message includes a forbidden character")


def store_in_a_file(results: List[Any], ui_out: Path) -> None:
    try:
        ui_out.parent.mkdir(parents=True, exist_ok=True)
        with ui_out.open('w') as ui_out_json:
            json.dump(results, ui_out_json, indent=4)
    except Exception as e:
        mutation_logger.warning(f"Failed to output to {ui_out}")
        mutation_logger.debug(f"{e}")


class WebUtils:
    def __init__(self, args: SimpleNamespace):
        self.max_timeout_attempts_count = \
            args.max_timeout_attempts_count or MConstants.DEFAULT_MAX_TIMEOUT_ATTEMPTS_COUNT
        self.request_timeout = args.request_timeout
        if args.server == MConstants.STAGING:
            domain = MConstants.STAGING_DOTCOM
            mutation_test_domain = MConstants.MUTATION_TEST_REPORT_STAGING
        elif args.server == MConstants.PRODUCTION:
            domain = MConstants.PROVER_DOTCOM
            mutation_test_domain = MConstants.MUTATION_TEST_REPORT_PRODUCTION
        elif args.server == MConstants.DEV:
            domain = MConstants.DEV_DOTCOM
            mutation_test_domain = MConstants.MUTATION_TEST_REPORT_DEV
        else:
            raise Util.CertoraUserInputError(f"Invalid server name {args.server}")
        self.mutation_test_id_url = f"https://{domain}/v1/domain/mutation-tests/initiate/"
        self.mutation_test_submit_final_result_url = f"https://{domain}/mutationTesting/getUploadInfo/"
        self.mutation_test_final_result_url = f"https://{mutation_test_domain}"
        mutation_logger.debug(f"Using server {args.server} with mutation_test_id_url {self.mutation_test_id_url}")

    def put_response_with_timeout(self, url: str, data: Any, headers: Dict[str, str]) -> Optional[requests.Response]:
        """
        Executes a put request and returns the response, uses a timeout mechanism

        Args
        ----
            url (str): the URL to send a PUT request to
            data (Any): the data to send
            headers (dict[str, str]): an optional set of headers

        Returns
        -------
            Optional[requests.Response]: if any of the attempt succeeded, returns the response
        """
        for i in range(self.max_timeout_attempts_count):
            try:
                return requests.put(url, data=data, timeout=self.request_timeout,
                                    headers=headers)
            except Exception:
                mutation_logger.debug(f"attempt {i} failed to put url {url}.")
        return None

    def put_json_request_with_timeout(self, url: str, body: Dict[str, Any],
                                      headers: Dict[str, str]) -> Optional[requests.Response]:
        """
        Executes a put request and returns the response, uses a timeout mechanism

        Args
        ----
            url (str): the URL to send a PUT request to
            body (dict[str, Any]): request body
            headers (dict[str, str]): an optional set of headers

        Returns
        -------
            Optional[requests.Response]: if any of the attempt succeeded, returns the response
        """
        for i in range(self.max_timeout_attempts_count):
            try:
                return requests.put(url, json=body, timeout=self.request_timeout,
                                    headers=headers)
            except Exception as e:
                mutation_logger.debug(f"attempt {i} failed to put url {url}. {e}")
        return None

    def get_response_with_timeout(self, url: str,
                                  cookies: Dict[str, str] = {}, stream: bool = False) -> Optional[requests.Response]:
        """
        Executes a get request and returns the response, uses a timeout mechanism

        Args
        ----
            url (str): the URL to send a GET request to
            cookies (dict[str, str]): an optional set of cookies/request data
            stream (bool): use a lazy way to download large files

        Returns
        -------
            Optional[requests.Response]: if any of the attempt succeeded, returns the response
        """
        for i in range(self.max_timeout_attempts_count):
            try:
                resp = requests.get(url, timeout=self.request_timeout, cookies=cookies, stream=stream)
                return resp
            except Exception:
                mutation_logger.info(f"attempt {i} failed to get url {url}.")
        return None


# SUBMIT PHASE FUNCTIONALITY


def check_key_exists() -> None:
    if KEY_ENV_VAR not in os.environ:
        raise Util.CertoraUserInputError("Cannot run mutation testing without a Certora key.")


class TreeViewStatus(StrEnum):
    RUNNING = "RUNNING"
    VERIFIED = "VERIFIED"
    SKIPPED = "SKIPPED"
    TIMEOUT = "TIMEOUT"
    ERROR = "ERROR"
    UNKNOWN = "UNKNOWN"
    SANITY_FAILED = "SANITY_FAILED"
    VIOLATED = "VIOLATED"


class FinalJobStatus(StrEnum):
    SUCCEEDED = "SUCCEEDED"
    FAILED = "FAILED"
    SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
    ERROR = "ERROR"
    LAMBDA_ERROR = "LAMBDA_ERROR"
    HALTED = "HALTED"

    @classmethod
    def get_statuses(cls) -> List[str]:
        return [s.value for s in cls]


def convert_to_mutation_testing_status(treeview_status: str) -> str:
    if (treeview_status == TreeViewStatus.VERIFIED) or (treeview_status == TreeViewStatus.SKIPPED):
        return MutationTestRuleStatus.SUCCESS.value
    elif treeview_status == TreeViewStatus.VIOLATED:
        return MutationTestRuleStatus.FAIL.value
    elif treeview_status == TreeViewStatus.TIMEOUT:
        return MutationTestRuleStatus.TIMEOUT.value
    elif treeview_status == TreeViewStatus.SANITY_FAILED:
        return MutationTestRuleStatus.SANITY_FAIL.value
    else:
        return MutationTestRuleStatus.UNKNOWN.value


def mutant_object_from_path(manual_mutant: Path, orig_file: Path) -> Mutant:
    """
    Generate a Mutant object for a manual mutation.

    Parameters:
    - `manual_mutant`: Path to the manual mutant file.
    - `orig_file`: Path to the original file.

    Returns:
    - A Mutant object representing the parsed manual mutant.

    """
    mutant_id = manual_mutant.stem
    return Mutant(
        filename=str(manual_mutant),
        original_filename=str(orig_file),
        directory=str(manual_mutant.parent),
        id=mutant_id,
        diff=get_diff(orig_file, manual_mutant),
        description=str(manual_mutant),  # NOTE: parse a description from the mutant source
        name=mutant_id
    )


class WebFetcher:
    def __init__(self, _web_utils: WebUtils, debug: bool = False):
        self.web_utils = _web_utils
        self.verification_report_path_pattern = re.compile(r"^\/output\/\d+\/[0-9a-fA-F]*(\/)?$")
        self.job_status_path_pattern = re.compile(r"^\/jobStatus\/\d+\/[0-9a-fA-F]*(\/)?$")
        self.debug = debug

    @staticmethod
    def get_url_path(url: str) -> Optional[str]:
        parsed_url = urllib3.util.parse_url(url)
        return parsed_url.path

    def get_resource_url(self, url: str, keyword: str) -> str:
        url_path = self.get_url_path(url)
        if not url_path:
            raise Util.CertoraUserInputError(f"Invalid URL was provided: {url}")

        # we check both the status page and the verification report for
        # backward compatibility
        if re.match(self.verification_report_path_pattern, url_path):
            resource_url = url.replace(MConstants.OUTPUT, keyword)
        elif re.match(self.job_status_path_pattern, url_path):
            resource_url = url.replace(MConstants.JOB_STATUS, keyword)
        else:
            raise Util.CertoraUserInputError(f"Unknown URL was provided: {url}")
        return resource_url

    def get_output_json(self, url: str) -> Optional[Dict[str, Any]]:
        output_url = self.get_resource_url(url, MConstants.OUTPUT)
        return self.parse_response(
            get_file_url_from_orig_url(output_url, MConstants.OUTPUTJSON),
            MConstants.OUTPUTJSON
        )

    def get_treeview_json(self, url: str) -> Optional[Dict[str, Any]]:
        progress_url = self.get_resource_url(url, MConstants.PROGRESS)
        return self.parse_response(progress_url, "treeview.json")

    def get_job_data(self, url: str) -> Optional[Dict[str, Any]]:
        job_data_url = self.get_resource_url(url, MConstants.JOB_DATA)
        return self.parse_response(job_data_url, "job data")

    def parse_response(self, url: str, resource: str) -> Optional[Dict[str, Any]]:
        response = self.web_utils.get_response_with_timeout(url)
        if response is None or response.status_code != 200:
            mutation_logger.debug(f"Got bad response code when fetching {resource} "
                                  f"{response.status_code if response else ''}")
            return None
        return response.json()

class MutateApp:
    conf: Path
    conf_no_flag: Path
    test: Optional[str]
    orig_run: Optional[str]
    msg: Optional[str]
    server: Optional[str]
    prover_version: Optional[str]
    debug: bool
    collect_mode: bool
    orig_run_dir: Optional[Path]
    outdir: Optional[Path]
    gambit_only: bool
    dump_failed_collects: Optional[Path]
    ui_out: Optional[Path]
    dump_link: Optional[Path]
    dump_csv: Optional[Path]
    sync: bool
    wait_for_original_run: bool
    url_visibility: Vf.UrlVisibilityOptions
    collect_file: Optional[Path]
    poll_timeout: Optional[int]
    max_timeout_attempts_count: Optional[int]
    request_timeout: Optional[int]
    gambit: Union[Dict, List, None]
    manual_mutants: Optional[Dict]
    universal_mutator: Optional[Dict]
    prover_context: CertoraContext

    def __init__(self, args_list: List[str]) -> None:
        self.mutation_test_id = ''
        self.numb_of_jobs: int = 0
        self.backup_path: Optional[Path] = None
        self.sources_dir: Optional[Path] = None
        self.with_split_stats_data = False
        self.manual_mutants_list: List[Mutant] = list()
        if args_list:
            self.get_args(args_list)
        self.read_conf_file()
        self.server = self.config_server()

    def is_soroban_run(self) -> bool:
        return (hasattr(self.prover_context, 'build_script') and self.prover_context is not None)

    def config_server(self) -> str:
        """
        If given a server, it is taken.
        Otherwise, computes from either the conf file or the orig run link.
        """
        # default production
        default = MConstants.PRODUCTION
        if self.server:
            return self.server

        prover_context = getattr(self, 'prover_context', {})
        if prover_context:
            server_in_prover_conf = getattr(prover_context, MConstants.SERVER, None)
            if server_in_prover_conf:
                return server_in_prover_conf

        if self.orig_run is not None:
            if MConstants.STAGING_DOTCOM in self.orig_run:
                return MConstants.STAGING
            elif MConstants.PROVER_DOTCOM in self.orig_run:
                return default
            elif MConstants.DEV_DOTCOM in self.orig_run:
                return MConstants.DEV
            else:
                raise Util.CertoraUserInputError(f"{self.orig_run} link has an unsupported domain.")
        else:
            return default

    def checks_before_settings_defaults(self) -> None:
        # if not self.orig_run:
        #     mutation_logger.warning("using --orig_run_dir without --orig_run has no effect")
        # else:
        #     self.orig_run_dir = MConstants.CERTORA_MUTATE_SOURCES
        pass

    def set_defaults(self) -> None:
        MutateApp.__annotations__["orig_run_dir"] = Optional[str]
        if self.orig_run_dir and self.orig_run_dir != MConstants.CERTORA_MUTATE_SOURCES:
            if self.orig_run_dir.exists():
                raise FileExistsError(f"orig_run_dir: {self.orig_run_dir} already exists")

        shutil.rmtree(str(MConstants.CERTORA_MUTATE_SOURCES), ignore_errors=True)
        if not self.orig_run_dir:
            self.orig_run_dir = MConstants.CERTORA_MUTATE_SOURCES

        if not self.dump_failed_collects:
            self.dump_failed_collects = MConstants.DEFAULT_DUMP_FAILED_COLLECTS
        if not self.collect_file:
            self.collect_file = MConstants.DEFAULT_COLLECT_FILE
        if not self.poll_timeout:
            self.poll_timeout = MConstants.DEFAULT_POLL_TIMEOUT_IN_SECS
        if not self.max_timeout_attempts_count:
            self.max_timeout_attempts_count = MConstants.DEFAULT_MAX_TIMEOUT_ATTEMPTS_COUNT
        if not self.request_timeout:
            self.request_timeout = MConstants.DEFAULT_REQUEST_TIMEOUT_IN_SECS
        if not self.ui_out:
            self.ui_out = MConstants.DEFAULT_UI_OUT
        if self.outdir and self.outdir != MConstants.GAMBIT_OUT:
            if Path(self.outdir).exists():
                raise FileExistsError(f"outdir: {self.outdir} already exists")
        shutil.rmtree(str(MConstants.GAMBIT_OUT), ignore_errors=True)
        shutil.rmtree(str(MConstants.APPLIED_MUTANTS_DIR), ignore_errors=True)

        if not self.outdir:
            self.outdir = MConstants.GAMBIT_OUT

    def fetch_and_extract_inputs_dir_from_orig_run(self) -> Path:
        if not self.orig_run:
            raise Util.ImplementationError("URL for original run is null")
        web_utils = WebUtils(SimpleNamespace(**vars(self)))
        zip_output_url = self.orig_run.replace(MConstants.OUTPUT, MConstants.ZIPOUTPUT)
        response = web_utils.get_response_with_timeout(zip_output_url, stream=True, cookies=default_cookies)
        if not response:
            raise RuntimeError("Could not fetch zip output from previous run. "
                               "Try running certoraMutate with a conf file.")
        if response.status_code == 200:
            with open(MConstants.ZIP_PATH, 'wb') as f:
                for chunk in response.iter_content(chunk_size=128):
                    f.write(chunk)
        else:
            raise RuntimeError(f"Failed to fetch inputs dir from {self.orig_run}. "
                               f"Got status code: {response.status_code}. Try running certoraMutate with a conf file.")
        try:
            extract = tarfile.open(MConstants.ZIP_PATH, "r")
            assert self.orig_run_dir, "fetch_and_extract_inputs_dir_from_orig_run: no orig_run_dir"
            extract.extractall(self.orig_run_dir)
            return self.orig_run_dir / MConstants.TARNAME / MConstants.INPUTS
        except Exception:
            raise Util.CertoraUserInputError(f"Failed to extract .certora_source from {self.orig_run}.")

    def print_notification_msg(self) -> None:
        if self.server == "staging":
            dashboard = MConstants.MUTATION_DASHBOARD_STAGING
        elif self.server == "vaas-dev":
            dashboard = MConstants.MUTATION_DASHBOARD_DEV
        else:
            dashboard = MConstants.MUTATION_DASHBOARD_PRODUCTION
        print(
            f"You will receive an email notification when this mutation test is completed. It may take several hours.\n"
            f"You can follow the test's progress at {dashboard}"
        )

    def settings_post_parsing(self) -> None:
        if self.debug:
            LoggingManager().set_log_level_and_format(debug=True)

        if self.gambit and not isinstance(self.gambit, list):
            self.gambit = [self.gambit]

    def read_conf_from_orig_run(self) -> None:
        """
        If the attribute orig_run is set all prover settings need to be fetched from the cloud based on the
        provided report link. Once we get it we copy it to the CWD all following calls to certoraRun will use
        this conf file. If the conf file contains the mutation object we delete it.
        certoraRun should in any case ignore this object, we delete it only for clarity
        :return: None
        """
        dict = vars(self.prover_context)
        if len(dict) != 1 and MConstants.MUTATIONS not in dict:
            raise Util.CertoraUserInputError("when running from orig run conf file should have just one  "
                                             "mutation object")

        self.fetch_and_extract_inputs_dir_from_orig_run()
        # we need orig_run_dir before we read it, and before we set defaults
        assert self.orig_run_dir, "read_conf_from_orig_run: no orig_run_dir"
        input_dir = self.orig_run_dir / MConstants.TARNAME / MConstants.INPUTS
        self.sources_dir = input_dir / MConstants.CERTORA_SOURCES
        self.prover_context = get_conf_from_certora_metadata(input_dir)
        with MConstants.ORIG_RUN_PROVER_CONF.open('w') as p_conf:
            json.dump(self.prover_context.__dict__, p_conf)
        shutil.copy(MConstants.ORIG_RUN_PROVER_CONF, self.sources_dir)
        # we replace prover_context from the one in orig_run. From the old prover_context, the one given in CLI or
        # conf file, we take the mutation object, overwrite a mutation object that may exist in the orig_run
        if MConstants.MUTATIONS in dict:
            setattr(self.prover_context, MConstants.MUTATIONS, dict[MConstants.MUTATIONS])

    def validate_mutants_in_source_tree(self) -> None:
        if self.manual_mutants:
            assert self.sources_dir, "validate_mutants_in_source_tree: no sources_dir"
            cwd_in_orig = find_cwd(self.sources_dir)
            for mutant in self.manual_mutants:
                file_to_mutate = mutant.get(MConstants.FILE_TO_MUTATE, None)
                if not (cwd_in_orig / file_to_mutate).exists():
                    raise Util.CertoraUserInputError(f"The mutated file, {file_to_mutate}, does not affect"
                                                     " verification results. Please remove or replace the mutation")

    def restore_backups(self) -> None:
        if self.backup_path:
            Util.restore_backup(self.backup_path)
        self.backup_path = None

    def submit_soroban(self) -> None:

        try:
            original_run_result = self.run_certora_prover(self.conf, self.mutation_test_id, msg=MConstants.ORIGINAL)
        except Exception as e:
            raise Util.CertoraUserInputError(f"Orig run: {e}")
        if not original_run_result:
            raise Util.CertoraUserInputError("No Orig results")
        result_link = original_run_result.rule_report_link
        assert result_link, "submit_soroban: Null result_link"

        mutation_logger.info("Generating mutants and submitting...")
        generated_mutants: List[Mutant] = []
        if self.universal_mutator:
            generated_mutants = self.get_universal_mutator_mutants()

        if self.manual_mutants:
            self.manual_mutants_list = self.get_manual_mutations_mutants()
            mutation_logger.debug(f"successfully parsed manual mutants from {self.conf}")

        all_mutants = generated_mutants + self.manual_mutants_list
        web_utils = WebUtils(SimpleNamespace(**vars(self)))
        # get the mutation test id
        self.numb_of_jobs = len(all_mutants)
        self.mutation_test_id, collect_presigned_url = self.get_mutation_test_id_request(web_utils,
                                                                                         self.numb_of_jobs + 1)
        mutation_logger.debug(f"Mutation test id: {self.mutation_test_id}")
        print_separator('PROVER START')
        # run original run. if it fails to compile, nothing to continue with

        try:
            original_run_result = self.run_certora_prover(self.conf, self.mutation_test_id, msg=MConstants.ORIGINAL)
        except Exception as e:
            raise Util.CertoraUserInputError(f"Orig run: {e}")
        if not original_run_result:
            raise Util.CertoraUserInputError("No Orig results")
        result_link = original_run_result.rule_report_link
        assert result_link, "submit_soroban: Null result_link"

        if not Vf.Util.is_valid_url(urllib3.util.parse_url(result_link)):
            raise Util.CertoraUserInputError(f"Invalid certoraRun result: {result_link}")
        print_separator('PROVER END')
        # run mutants
        mutant_runs = []
        try:
            for mutant in all_mutants:
                # call certoraRun for each mutant we found
                mutant_runs.append(self.run_mutant_soroban(mutant))
                self.restore_backups()
        finally:
            if self.backup_path:
                mutation_logger.warning("Not empty backup path")
                self.restore_backups()

        # wrap it all up and make the input for the 2nd step: the collector
        assert self.collect_file, "submit_soroban: no collect_file"
        with self.collect_file.open('w+') as collect_file:
            collect_data = {MConstants.ORIGINAL: result_link,
                            MConstants.MUTANTS: [dataclasses.asdict(m) for m in mutant_runs]}
            json.dump(collect_data, collect_file, indent=4)

        self.upload_file_to_cloud_storage(web_utils, collect_presigned_url, collect_data)
        self.print_notification_msg()
        mutation_logger.info(f"... completed submit phase! Now we poll on {self.collect_file}...")

    def submit_evm(self) -> None:
        mutation_logger.info("Generating mutants and submitting...")

        # ensure .certora_internal exists
        os.makedirs(Util.CERTORA_INTERNAL_ROOT, exist_ok=True)

        self.sources_dir = None

        # call gambit
        generated_mutants = []

        if getattr(self, MConstants.GAMBIT, False):
            generated_mutants = self.run_gambit()
        elif self.gambit_only:
            raise Util.CertoraUserInputError("gambit section must exist when running with 'gambit_only'")
        if self.gambit_only:
            sys.exit(0)  # we exit here so we will not continue with collect in case of sync mode

        if self.manual_mutants:
            self.manual_mutants_list = self.get_manual_mutations_mutants()
            mutation_logger.debug(f"successfully parsed manual mutants from {self.conf}")

        # match a generated mutant to a directory where we will apply the diff
        generated_mutants_with_target_dir = []
        for mutant in generated_mutants:
            generated_mutants_with_target_dir.append((mutant, MConstants.APPLIED_MUTANTS_DIR / f"mutant{mutant.id}"))
        manual_mutants_with_target_dir = []
        for mutant in self.manual_mutants_list:
            manual_mutants_with_target_dir.append((mutant, MConstants.APPLIED_MUTANTS_DIR / f"manual{mutant.id}"))
        all_mutants_with_target_dir = generated_mutants_with_target_dir + manual_mutants_with_target_dir
        mutation_logger.debug("Associated each mutant to a target directory where the mutant will be applied to the "
                              "source code")
        web_utils = WebUtils(SimpleNamespace(**vars(self)))
        # get the mutation test id
        numb_of_jobs = len(all_mutants_with_target_dir) if self.orig_run else len(all_mutants_with_target_dir) + 1
        mutation_test_id, collect_presigned_url = self.get_mutation_test_id_request(web_utils, numb_of_jobs)
        mutation_logger.debug(f"Mutation test id: {mutation_test_id}")
        MutUtil.TOTAL_MUTANTS = numb_of_jobs
        if not self.orig_run:
            prover_conf = self.conf
            mutation_logger.warning("Running without a link to a previously successful prover run on "
                                    "the original contract. So we will first submit the original Prover configuration. "
                                    "No source mutations...")
            print_separator('PROVER START')
            # run original run. if it fails to compile, nothing to continue with

            try:
                certora_run_result = self.run_certora_prover(self.conf, mutation_test_id, msg=MConstants.ORIGINAL)
            except CertoraFoundViolations:  # violations should not stop execution
                pass
            except Exception as e:
                raise Util.CertoraUserInputError(f"Orig run: {e}")

            if certora_run_result:
                self.sources_dir = certora_run_result.src_dir

                assert certora_run_result.rule_report_link, "submit_evm: no certora_run_result.rule_report_link"
                result_link = certora_run_result.rule_report_link
                if self.wait_for_original_run:
                    if wait_for_job(result_link) and download_report_file(result_link, MConstants.SPLIT_STATS_DATA):
                        self.with_split_stats_data = True

            if not certora_run_result or not result_link or not self.sources_dir:
                try:
                    if self.dump_failed_collects:
                        with self.dump_failed_collects.open('w') as failed_collection:
                            failed_collection.write("failed to collect original run")
                except Exception as e:
                    mutation_logger.debug(f"Couldn't write to collection failures file:{e}")
                raise Util.CertoraUserInputError("Original run was not successful. Cannot run mutation testing.")

            if not Vf.Util.is_valid_url(urllib3.util.parse_url(result_link)):
                raise Util.CertoraUserInputError(f"Invalid certoraRun result: {result_link}")
            print_separator('PROVER END')
        else:  # orig_run is not None
            print_separator('USING PREVIOUS RUN LINK')
            result_link = self.orig_run
            input_dir = self.fetch_and_extract_inputs_dir_from_orig_run()
            self.sources_dir = input_dir / MConstants.CERTORA_SOURCES
            prover_conf_content = get_conf_from_certora_metadata(input_dir)
            prover_conf = Path(MConstants.ORIG_RUN_PROVER_CONF)
            with prover_conf.open('w') as p_conf:
                json.dump(prover_conf_content.__dict__, p_conf)
            shutil.copy(prover_conf, self.sources_dir)
            assert self.orig_run_dir, "submit_evm: orig_run_dir"
            src = self.orig_run_dir / MConstants.TARNAME / MConstants.REPORTS / MConstants.SPLIT_STATS_DATA
            if src.is_file():
                try:
                    shutil.copy(src, Path.cwd())
                    self.with_split_stats_data = True
                except Exception as e:
                    logging.debug(f"did not manage to copy {MConstants.SPLIT_STATS_DATA} from {src.parent} "
                                  f"to current directory {Path.cwd()}: {e}")
            else:
                logging.debug(f"{MConstants.SPLIT_STATS_DATA} is not in orig run: {src.parent}")

        self.validate_mutants_in_source_tree()
        all_mutants = [(mutant, self.sources_dir, trg_dir) for mutant, trg_dir in all_mutants_with_target_dir]
        for m in all_mutants:
            self.build_mutant_directories(*m)

        if self.test == str(Util.TestValue.AFTER_BUILD_MUTANTS_DIRECTORY):
            raise Util.TestResultsReady(self)

        self.common_solc_flags = self.get_common_solc_flags()
        for mutant, trg_dir in manual_mutants_with_target_dir:
            self.compile_manual_mutants(mutant, trg_dir)

        mutation_logger.info("Submit mutations to Prover...")
        print_separator('PROVER START')

        num_processes_for_mp = None
        max_task_per_worker = None

        # For debug call run_mutant_evm in same process
        # runs = [(mutant, trg_dir, prover_conf,
        #          mutation_test_id, numb_of_jobs) for mutant, trg_dir in all_mutants_with_target_dir]
        # mutant_runs = []
        # for run in runs:
        #     mutant_runs.append(self.run_mutant_evm(*run))
        with multiprocessing.Pool(processes=num_processes_for_mp, maxtasksperchild=max_task_per_worker) as pool:
            mutant_runs = pool.starmap(self.run_mutant_evm,
                                       [(mutant, trg_dir, prover_conf, mutation_test_id, numb_of_jobs)
                                        for mutant, trg_dir in all_mutants_with_target_dir])

        print_separator('PROVER END')

        mutation_logger.debug("Completed submitting all mutant runs")
        mutation_logger.debug(result_link)
        mutation_logger.debug([dataclasses.asdict(m) for m in mutant_runs])

        # wrap it all up and make the input for the 2nd step: the collector
        assert self.collect_file, "submit_evm: no collect_file"
        with self.collect_file.open('w+') as collect_file:
            collect_data = {MConstants.ORIGINAL: result_link,
                            MConstants.MUTANTS: [dataclasses.asdict(m) for m in mutant_runs]}
            json.dump(collect_data, collect_file, indent=4)

        self.upload_file_to_cloud_storage(web_utils, collect_presigned_url, collect_data)
        self.print_notification_msg()
        mutation_logger.info(f"... completed submit phase! Now we poll on {self.collect_file}...")

    def build_mutant_directories(self, mutant: Mutant, src_dir: Path, trg_dir: Path) -> None:
        # first copy src_dir
        Util.safe_copy_folder(src_dir, trg_dir, shutil.ignore_patterns())  # no ignored patterns

        # now apply diff.
        # Remember: we are always running certoraMutate from the project root.
        file_path_to_mutate = trg_dir / Path(mutant.original_filename)

        # apply the mutated file in the newly rooted path
        if mutant.filename.endswith('.patch'):
            mutant.apply_patch(file_path_to_mutate)
        elif mutant.filename.endswith('.sol'):
            shutil.copy(mutant.filename, file_path_to_mutate)
        else:
            raise Util.CertoraUserInputError(f"mutant {mutant.filename} - must end with .sol or .patch")

        if self.with_split_stats_data and os.environ.get("WITH_AUTOCONFING", False) == '1':
            shutil.copy(Path.cwd() / MConstants.SPLIT_STATS_DATA, find_cwd(trg_dir))

    def run_mutant_evm(self, mutant: Mutant, trg_dir: Path, orig_conf: Path,
                       mutation_test_id: str, numb_of_jobs: int) -> MutantJob:

        MutUtil.TOTAL_MUTANTS = numb_of_jobs
        with Util.change_working_directory(find_cwd(trg_dir)):
            # we have conf file in sources, let's run from it, it will have proper filepaths

            try:
                certora_run_result = self.run_certora_prover(orig_conf, mutation_test_id, msg=f"mutant ID: {mutant.id}")
            except Util.CertoraUserInputError:
                return MutantJob(
                    gambit_mutant=mutant,
                    success=False,
                    link=None,
                    run_directory=None,
                    rule_report_link=None
                )

            assert certora_run_result, f"run_mutant_evm: no result {mutant.id} "

            link = certora_run_result.link
            self.sources_dir = certora_run_result.src_dir

            return MutantJob(
                gambit_mutant=mutant,
                success=True,
                link=link,
                run_directory=str(self.sources_dir),
                rule_report_link=certora_run_result.rule_report_link
            )

    def run_mutant_soroban(self, mutant: Mutant) -> MutantJob:
        file_to_mutate = Path(mutant.original_filename)
        # create a backup copy (if needed) by adding '.backup' to the end of the file
        if self.backup_path and Util.get_backup_path(file_to_mutate) != self.backup_path:
            raise Util.ImplementationError("backup_path is set but does not match the file to mutate")
        if not self.backup_path:
            self.backup_path = Util.create_backup(file_to_mutate)
            assert self.backup_path, f"run_mutant_soroban: create_backup for {file_to_mutate} failed"
        shutil.copy(mutant.filename, file_to_mutate)

        try:
            certora_run_result = self.run_certora_prover(self.conf, self.mutation_test_id, msg=f"mutant ID: {mutant.id}")
        except Util.CertoraUserInputError as e:
            Console().print(f"\n\nrun_mutant_soroban: prover failed\n{e}\n")
            return MutantJob(
                gambit_mutant=mutant,
                success=False,
                run_directory="",
                link=None,
                rule_report_link=None
            )

        assert certora_run_result, f"run_mutant_soroban: no result {mutant.id} "

        link = certora_run_result.link

        return MutantJob(
            gambit_mutant=mutant,
            success=True,
            run_directory="",
            link=link,
            rule_report_link=certora_run_result.rule_report_link
        )

    def get_solc_version(self, path_to_file: Path) -> str:
        solc = getattr(self.prover_context, 'solc', '')
        compiler_map = getattr(self.prover_context, 'solc_map', None) or getattr(self.prover_context, 'compiler_map', None)
        if solc and compiler_map:
            raise Util.CertoraUserInputError("You cannot use both 'solc' and 'solc_map/compiler_map' arguments: "
                                             f"solc is {solc} and solc_map/compiler_map is {compiler_map}")
        if solc:
            compiler = solc
        elif compiler_map:
            Cv.check_contract_name_arg_inputs(self.prover_context)
            compiler = get_relevant_compiler(path_to_file, self.prover_context)
            if not compiler:
                raise Util.CertoraUserInputError(f"Cannot resolve Solidity compiler for {path_to_file}: "
                                                 f"solc is {solc} and solc_map/compiler_map: {compiler_map}")
        else:
            compiler = Util.DEFAULT_SOLC_COMPILER

        exec_file = shutil.which(compiler)
        if exec_file is None:
            raise Util.CertoraUserInputError(f"{compiler} is not a valid executable")
        return compiler

    def filter_packages(self) -> List[str]:
        '''
        The packages that will be in the gambit conf file are the packages that exists in the source tree.
        redundant path parts that are identical between the package source and the package target are removed
        :return: List of package dependencies
        '''

        result_set = set()  # set to automatically remove duplicates
        packages = getattr(self.prover_context, 'packages', None)
        if not packages:
            raise Util.ImplementationError("calling filter_packages() with no packages")
        for package in packages:
            try:
                src, target = package.split('=')
            except Exception as e:
                raise Util.ImplementationError(f"filter_packages: {package} is not of the form str1=str2\n{e}")

            # remove trailing '/'
            if src.endswith('/'):
                src = src[:-1]
            if target.endswith('/'):
                target = target[:-1]

            if not Path(target).exists():
                continue

            src_parts = src.split('/')
            target_parts = target.split('/')
            # remove path parts from the end that are identical in source and target
            while len(src_parts) > 1 and len(target_parts) > 1 and src_parts[-1] == target_parts[-1]:
                src_parts.pop()
                target_parts.pop()
            result_set.add(f"{'/'.join(src_parts)}={'/'.join(target_parts)}")
        return list(result_set)

    def generate_gambit_conf_file(self) -> None:
        """
        the gambit conf file is generated based on the gambit section in the mutation conf file
        plus attributes taken from the prover conf (e.g. packages, solc flags etc)
        :return:
        """
        shared_attributes: Dict[str, Union[bool, str, List[str]]] = {}  # attributes that are shared by all gambit runs
        if not self.outdir:
            raise Util.ImplementationError("generate_gambit_conf_file: outdir is not set")
        shared_attributes['outdir'] = str(self.outdir)
        if hasattr(self.prover_context, 'packages'):
            filtered_packages = self.filter_packages()
            if filtered_packages:
                shared_attributes['solc_remappings'] = filtered_packages
        if getattr(self.prover_context, 'solc_optimize', None) or \
           getattr(self.prover_context, 'solc_optimize_map', None):
            shared_attributes['solc_optimize'] = True
        solc_allow_path = getattr(self.prover_context, 'solc_allow_path', None)
        shared_attributes['solc_allow_paths'] = ['.']
        if solc_allow_path:
            shared_attributes['solc_allow_paths'] += solc_allow_path
        evm_version = getattr(self.prover_context, 'solc_evm_version', None)
        if evm_version:
            shared_attributes['solc_evm_version'] = evm_version
        assert self.gambit, "submit_evm: no gambit"
        for gambit_obj in self.gambit:
            if MConstants.NUM_MUTANTS not in gambit_obj:
                gambit_obj[MConstants.NUM_MUTANTS] = DEFAULT_NUM_MUTANTS
            else:
                gambit_obj[MConstants.NUM_MUTANTS] = int(gambit_obj[MConstants.NUM_MUTANTS])
            gambit_obj[MConstants.SOLC] = self.get_solc_version(Path(gambit_obj[MConstants.FILENAME]))
            gambit_obj.update(shared_attributes)
        with MConstants.TMP_GAMBIT_PATH.open('w') as f:
            json.dump(self.gambit, f)

    def run_gambit(self) -> List[Mutant]:
        assert self.gambit, "run_gambit: gambit"
        self.generate_gambit_conf_file()

        gambit_exec = get_gambit_exec()
        gambit_args = [gambit_exec, "mutate", "--json", str(MConstants.TMP_GAMBIT_PATH)]
        mutation_logger.debug(f"Running gambit: {gambit_args}")
        run_result = subprocess.run(gambit_args, shell=False, universal_newlines=True, stderr=subprocess.PIPE,
                                    stdout=subprocess.PIPE)

        if run_result.returncode or run_result.stderr:
            mutation_logger.info(run_result.stdout)
            mutation_logger.info(run_result.stderr)
            raise Util.CertoraUserInputError("Gambit run failed", more_info=run_result.stderr)

        mutation_logger.debug("Completed gambit run successfully.")

        # read gambit_results.json
        ret_mutants: List[Mutant] = []
        assert self.outdir, "run_gambit: self.outdir"
        with (self.outdir / "gambit_results.json").open() as gambit_output_json:
            gambit_output = json.load(gambit_output_json)
            if not gambit_output:
                raise Util.CertoraUserInputError("No Gambit mutants were generated. "
                                                 "filename may be trivial or contain no mutable code.")
            for gambit_mutant_data in gambit_output:
                ret_mutants.append(
                    Mutant(
                        filename=str(self.outdir / gambit_mutant_data[MConstants.NAME]),
                        original_filename=gambit_mutant_data[MConstants.ORIGINAL],
                        # should be relative to re-root in target dir
                        directory=str(self.outdir / MConstants.MUTANTS / gambit_mutant_data[MConstants.ID]),
                        id=gambit_mutant_data[MConstants.ID],
                        diff=gambit_mutant_data[MConstants.DIFF],
                        description=gambit_mutant_data[MConstants.DESCRIPTION]
                    )
                )

        if MConstants.TMP_GAMBIT_PATH.exists() and not self.debug:
            os.remove(MConstants.TMP_GAMBIT_PATH)
        if not ret_mutants:
            raise Util.CertoraUserInputError("Could not generate Gambit mutants")
        mutation_logger.debug("Got mutant information")
        return ret_mutants

    def valid_extensions(self) -> List[str]:
        if self.is_soroban_run():
            return ['.rs']
        else:
            return ['.sol', '.patch']

    def add_file_to_mutants(self, mutants: List[Mutant], mutation_file: Path, file_to_mutate: Path) -> None:

        if mutation_file.suffix in self.valid_extensions():
            mutant_obj = mutant_object_from_path(mutation_file, file_to_mutate)
            mutants.append(mutant_obj)
        else:
            raise Util.CertoraUserInputError(f"Illegal mutant file, {mutation_file.name},"
                                             f" extension must be {' or '.join(self.valid_extensions())}")

    def add_dir_to_mutants(self, manual_mutants: List[Mutant], mutation_dir: Path, file_to_mutate: Path) -> None:
        mutation_dir_path = Path(mutation_dir)
        manual_mutants_files = \
            [p for p in mutation_dir_path.glob('*') if p.is_file() and p.suffix in self.valid_extensions()]
        if not manual_mutants_files:
            raise Util.CertoraUserInputError(
                f"Could not find any manual mutation files at {mutation_dir}")
        for file in manual_mutants_files:
            self.add_file_to_mutants(manual_mutants, file, file_to_mutate)

    def get_manual_mutations_mutants(self) -> List[Mutant]:
        """
        Get manual mutations from manual_mutations attribute

        Returns:
        - A list of Mutant objects representing the found manual mutations.

        Raises:
        - `CertoraUserInputError`: If the mutation configuration file does not exist.
        """

        ret_mutants: List[Mutant] = []
        assert self.manual_mutants, "get_manual_mutations_mutants: self.manual_mutants"
        for mutant in self.manual_mutants:
            file_to_mutate = Path(os.path.normpath(mutant[MConstants.FILE_TO_MUTATE]))
            path_to_orig = Path(file_to_mutate).resolve()
            if not path_to_orig.exists():
                raise Util.CertoraUserInputError(f"Original file '{path_to_orig}' for manual mutations does not exist")
            mutants_location = Path(mutant[MConstants.MUTANTS_LOCATION])
            if mutants_location.is_dir():
                self.add_dir_to_mutants(ret_mutants, mutants_location, file_to_mutate)
            elif mutants_location.is_file():
                self.add_file_to_mutants(ret_mutants, mutants_location, file_to_mutate)
            else:
                raise Util.CertoraUserInputError(f"{mutants_location} not a valid file or directory")
        return ret_mutants

    def get_universal_mutator_mutants(self) -> List[Mutant]:
        """
        Get manual mutations from universal_mutator attribute

        Returns:
        - A list of Mutant objects representing the found manual mutations.

        Raises:
        - `CertoraUserInputError`: If the mutation configuration file does not exist.
        """

        ret_mutants: List[Mutant] = []
        assert self.universal_mutator, "get_universal_mutator_mutants: self.universal_mutator"
        for mutant in self.universal_mutator:
            file_to_mutate = Path(os.path.normpath(mutant[MConstants.FILE_TO_MUTATE]))
            mutants_location = Path(mutant[MConstants.MUTANTS_LOCATION])
            num_of_mutants = int(mutant[MConstants.NUM_MUTANTS])
            run_universal_mutator(file_to_mutate, self.prover_context.build_script, mutants_location, num_of_mutants)
            self.add_dir_to_mutants(ret_mutants, mutants_location, file_to_mutate)
        return ret_mutants

    def verify_orig_build(self) -> None:
        if self.orig_run:  # if orig run exists we will not try to build again
            return
        try:
            args = ["--compilation_steps_only"]
            if self.prover_version:
                args += ['--prover_version', self.prover_version]
            if self.server:
                args += ['--server', self.server]
            run_certora([str(self.conf)] + args)
        except CertoraFoundViolations:  # violations should not stop execution
            pass
        except Exception as e:
            raise Util.CertoraUserInputError(f"running original from {self.conf} failed\n", e) from None

    def run_certora_prover(self, conf_file: Path, mutation_test_id: str = "",
                           msg: str = "") -> Optional[CertoraRunResult]:

        if "run_source" in vars(self.prover_context):
            mutation_logger.debug(
                f"Conf object already has a run source: {self.prover_context.run_source}")  # significance?

        certora_args = [str(conf_file), "--run_source", "MUTATION", "--msg", msg]
        if self.wait_for_original_run:
            certora_args.append("--wait_for_results")
        if self.url_visibility:
            certora_args.extend(["--url_visibility", str(self.url_visibility)])
        if self.with_split_stats_data and os.environ.get("WITH_AUTOCONFING", False) == '1':
            certora_args += ['--prover_resource_files', f"ac:{MConstants.SPLIT_STATS_DATA}"]
        if mutation_test_id:
            certora_args.extend(["--mutation_test_id", mutation_test_id])
        if self.server:
            certora_args.extend(["--server", self.server])
        if self.prover_version:
            certora_args.extend(["--prover_version", self.prover_version])
        elif Util.is_local(self.prover_context):
            certora_args.extend(["--prover_version", 'master'])

        if not self.is_soroban_run():
            certora_args.extend(["--disable_local_typechecking"])
        mutation_logger.debug(f"Running the Prover: {certora_args}")
        try:
            if self.is_soroban_run():
                certora_run_result = run_soroban_prover(certora_args)
            else:
                certora_run_result = run_certora(certora_args)
        except CertoraFoundViolations as e:
            assert e.results, "expect e.results not to be None"
            certora_run_result = e.results
        except Exception as e:
            raise Util.CertoraUserInputError(f"certoraRun failed with the parameters:\n {' '.join(certora_args)}"
                                             f"\n\n{e}", e)

        return certora_run_result

    def load_test_results(self) -> TestJobs:
        """
        Load mutation testing results from a collection file.

        Returns:
        - A tuple containing the original URL and a list of mutants' results.

        Raises:
        - `Util.CertoraUserInputError`: Raised if the collection file is missing, or if the original URL or mutants
          information is not found in the collection file.
        """

        if not self.collect_file:
            raise Util.ImplementationError("load_test_results: collect_file was not set")
        try:
            Vf.validate_readable_file(str(self.collect_file))
        except Exception as e:
            raise Util.CertoraUserInputError(f"{self.collect_file} is not a readable file\n{e}") from None

        with self.collect_file.open() as collect_handle:
            results_work = json.load(collect_handle)

        if MConstants.ORIGINAL not in results_work:
            raise Util.CertoraUserInputError(f"Could not find original url in {self.collect_file}.")

        if MConstants.MUTANTS not in results_work:
            raise Util.CertoraUserInputError(f"Could not find mutants in {self.collect_file}.")

        if self.test == str(Util.TestValue.AFTER_COLLECT):
            raise Util.TestResultsReady(results_work)

        mutation_logger.info(f"Collecting results from {self.collect_file}...")

        test_results = TestJobs(**results_work)

        return test_results

    def get_report_fetcher(self, url: str) -> WebFetcher:
        """
        :param url: Either a file path or a web url
        :return: WebFetcher
        """

        web_utils = WebUtils(SimpleNamespace(**vars(self)))
        return WebFetcher(web_utils, self.debug)

    def fetch_mutant_results(self, mutants_jobs: List[MutantJob], fetcher: WebFetcher) \
            -> Optional[List[MutantJobWithResults]]:
        """
        Retrieve mutant verification results for a list of mutant job objects.

        Parameters:
        - `mutants_jobs`: A list of mutant jobs to fetch results for.
        - `fetcher`: An instance of the WebFetcher class used to fetch mutant results.

        Returns:
        - A list of mutant results - tuples containing mutant objects and their corresponding results.
        - None if at least one of the mutant verification jobs did not terminate
        """
        all_mutants_results: List[MutantJobWithResults] = []
        for mutant_job in mutants_jobs:
            mutant_link = mutant_job.link
            if not mutant_link:
                return None
            try:
                mutant_run_results = self.get_results(mutant_link, fetcher)
            except Util.BadMutationError:
                # we do not want to abort the mutation testing all together just because Prover failed.
                # Some mutants may compile but fail during runtime. We just should ignore them in the final report
                mutant_job.success = False
                continue
            if not mutant_run_results:
                return None
            mutant_results = MutantJobWithResults(mutant_job, mutant_run_results)

            all_mutants_results.append(mutant_results)
            mutation_logger.info(f"Successfully retrieved results for mutant {mutant_job}")
        return all_mutants_results

    def collect(self) -> bool:
        """
        Returns true if finished collecting.
        Returns false if not.
        Failing to get results from original run will raise an exception
        Failing to get results from a mutant will add the mutant to failed_collection but will not throw an exception
        """
        test_results = self.load_test_results()

        if test_results.original is None or (not valid_link(test_results.original)):
            raise Util.CertoraUserInputError("There is no original URL - nothing to collect.")

        fetcher = self.get_report_fetcher(test_results.original)
        try:
            original_results = self.get_results(test_results.original, fetcher)
        except RunTimedout:
            raise RuntimeError(f"Original run timed out, link - {test_results.original}")
        original_run_results: Optional[MutantJobWithResults] = None

        if original_results is None:
            return False

        # Construct a MutantJobWithResults for the original run
        orig_mutant = Mutant(
            description="The original file without any mutation",
            diff="",
            id=MConstants.ORIGINAL,
            directory="",
            filename="",
            original_filename="",
            name=MConstants.ORIGINAL
        )
        mutant_job = MutantJob(
            gambit_mutant=orig_mutant,
            link=test_results.original,
            success=True,
            run_directory="",
            rule_report_link=test_results.original.replace(MConstants.JOB_STATUS, MConstants.OUTPUT)
        )
        original_run_results = MutantJobWithResults(
            mutant_job=mutant_job,
            rule_results=original_results
        )

        # build mutants object with the rule results
        mutants_results = self.fetch_mutant_results(test_results.mutants, fetcher)

        if mutants_results is None:  # At least one run did not terminate yet
            return False

        # at this point we know that original run and all mutant runs completed

        failed_mutants_found = False
        if len(mutants_results) == 0:
            raise MutUtil.EmptyMutationReport("No successful mutants found")
        if self.dump_failed_collects:
            with self.dump_failed_collects.open('w+') as failed_collection:
                # add mutants
                for mutant_result in mutants_results:
                    if not mutant_result.mutant_job.success:
                        failed_collection.write(f"{mutant_result}\n\n")
                        failed_mutants_found = True

        if failed_mutants_found and not self.sync:
            mutation_logger.warning(f"Failed to get results for some mutants. See {self.dump_failed_collects} "
                                    f"and try to manually run the prover on them to see the outcome.")

        mutants_results_not_none: List[MutantJobWithResults] = mutants_results or []
        assert original_run_results, "collect: original_run_results"

        if self.ui_out:
            # By the boolean conditions above we know these are not None
            self.write_report_file(original_run_results, mutants_results)
        else:
            raise Util.ImplementationError("collect: ui out not defined")

        if self.dump_csv:
            # By the boolean conditions above we know mutants_results is not None
            all_results = [original_run_results] + mutants_results_not_none
            self.generate_csv(all_results)
        mutation_logger.info("Done successfully collecting results!")

        if self.test == str(Util.TestValue.AFTER_GENERATE_COLLECT_REPORT):
            raise Util.TestResultsReady(self)

        return True

    def generate_csv(self, verification_results: List[MutantJobWithResults]) -> None:
        """
        Generate a CSV file based on mutation testing results and verification results of the original unmutated file.
        We also include results of failed runs, or rules that failed in the original run.

        The CSV will be of form:
            RULENAME,Original, 1_C, 2_C, ..., C.m1, C.m2, ...
            rule_name,SUCCESS,FAIL,TIMEOUT/UNKNOWN,....
        """
        if not verification_results:
            mutation_logger.warning("Could not write csv file as mutation test results are empty")
            return

        try:
            assert self.dump_csv, "generate_csv: self.dump_csv"
            with self.dump_csv.open(mode='w', newline='') as ui_out_csv:
                wr = csv.writer(ui_out_csv, delimiter=',')

                # Writing csv header - ruleName,Original,1_C,2_C,...,C.m1,C.m2,...
                # Rule names are the same for every mutant
                row1 = [MConstants.RULENAME] + [str(mutant.mutant_job) for mutant in verification_results]
                wr.writerow(row1)

                # We construct a row per rule
                rule_names = verification_results[0].all_rule_names()  # Same rule names for all mutants
                for rule_name in rule_names:
                    statuses = [mutant.get_rule_status(rule_name) for mutant in
                                verification_results]  # A list of a status per rule
                    row = [rule_name] + statuses
                    wr.writerow(row)

        except Exception as e:
            mutation_logger.warning(f"Failed to output csv to {self.dump_csv}.")
            mutation_logger.debug(f"{e}")

    def write_report_file(self, original_result: MutantJobWithResults,
                          mutants_results: List[MutantJobWithResults]) -> None:
        """
        Write a report file based on the original and mutated results.

        Parameters:
        - original_result (MutantJobWithResults): The result of the original job.
        - mutants_results (List[MutantJobWithResults]): List of mutated job results.

        This function filters out rules that failed on the original job and generates a report file.
        The report file includes relevant information from the mutated results, and does not include the original.

        Note: The generated report is stored in the 'ui_out' file.
        """
        rule_statuses_to_show = [MutationTestRuleStatus.SUCCESS, MutationTestRuleStatus.SANITY_FAIL]
        valid_rule_names = set([result.name for result in original_result.rule_results
                                if result.status in rule_statuses_to_show])

        valid_rule_names.discard(ENV_FREE_CHECK)

        if not valid_rule_names:
            if original_result.mutant_job.link:
                Console().print(f"\n\n{Util.print_rich_link(original_result.mutant_job.link)}\n")
            raise MutUtil.EmptyMutationReport("No valid rules in original report")

        filtered_mutation_results: List[dict] = []  # Doesn't include rules that failed on original

        for mutant_result in mutants_results:
            relevant_rule_results = \
                [rule_result for rule_result in mutant_result.rule_results if
                 rule_result.name in valid_rule_names]

            if relevant_rule_results:
                mutant_job_with_result = MutantJobWithResults(
                    mutant_job=mutant_result.mutant_job,
                    rule_results=relevant_rule_results
                )
                filtered_mutation_results.append(mutant_job_with_result.to_report_json())

        assert self.ui_out, "write_report_file: self.ui_out"
        store_in_a_file(filtered_mutation_results, self.ui_out)

    def get_mutation_test_id_request(self, web_utils: WebUtils, mutants_number: int) -> Tuple[str, str]:
        mutation_logger.debug(f"Getting mutation test ID for {mutants_number} mutants")
        url = web_utils.mutation_test_id_url
        body = {"mutants_number": mutants_number}  # type: Dict[str, Any]
        if self.orig_run:
            # we have validated this URL before
            parsed_url = urllib3.util.parse_url(self.orig_run)
            # ignore the query parameters and fragments
            body["original_job_url"] = f"{parsed_url.scheme}://{parsed_url.hostname}{parsed_url.path}"
        if self.msg:
            body["msg"] = self.msg
        mutation_logger.debug("Sending a PUT request with the body:", body)
        resp = web_utils.put_json_request_with_timeout(url, body, headers=auth_headers)
        if resp is None:
            raise Util.CertoraUserInputError("failed to send mutation test to server")
        if resp.status_code != 200:
            raise RuntimeError(f"Connection to server failed!\nurl: {url}")
        mutation_logger.debug(f"Got mutation test ID response: {resp.status_code}")
        resp_obj = resp.json()
        if MConstants.ID not in resp_obj or MConstants.COLLECT_SIGNED_URL not in resp_obj:
            raise RuntimeError(f"invalid server response, mutation test failed: {resp_obj}")

        return resp_obj[MConstants.ID], resp_obj[MConstants.COLLECT_SIGNED_URL]

    def get_mutation_test_final_report_url(self, web_utils: WebUtils) -> Tuple[str, str, str]:
        mutation_logger.debug("Getting mutation test final report URL")
        url = web_utils.mutation_test_submit_final_result_url
        resp = web_utils.get_response_with_timeout(url, cookies=default_cookies)
        if not resp:
            raise Util.CertoraUserInputError("Failed to get the mutation test report URL")
        mutation_logger.debug(f"Got response: {resp.status_code}")
        resp_obj = resp.json()
        if MConstants.ID in resp_obj and MConstants.PRE_SIGNED_URL in resp_obj and MConstants.ANONYMOUS_KEY in resp_obj:
            return resp_obj[MConstants.ID], resp_obj[MConstants.ANONYMOUS_KEY], resp_obj[MConstants.COLLECT_SIGNED_URL]
        else:
            raise RuntimeError(f"Couldn't generate the report URL: {resp_obj}")

    @staticmethod
    def upload_file_to_cloud_storage(web_utils: WebUtils, presigned_url: str, data: Any) -> None:
        mutation_logger.debug("Uploading file")
        headers = {"Content-Type": "application/json"}
        put_resp = web_utils.put_response_with_timeout(presigned_url, json.dumps(data), headers)
        if not put_resp:
            raise ConnectionError(f"Failed to submit to presigned URL. url - {presigned_url}")
        mutation_logger.debug(f"Upload file finished with: {put_resp.status_code}")
        if put_resp.status_code != 200:
            raise ConnectionError(f"Failed to submit to presigned URL, status code {put_resp.status_code}")

    @staticmethod
    def get_results(link: Optional[str], fetcher: WebFetcher) -> Optional[List[RuleResult]]:
        """
        :param link: A link to get the results from
        :param fetcher:
        :return: - A list of rule verification results.
                   If the job did not terminate yet, returns None.
        """
        if link is None:
            raise Util.ImplementationError("link is null")

        job_data = fetcher.get_job_data(link)
        if job_data is None:
            raise Util.ImplementationError(f"failed to get job data for {link}")

        job_status = job_data.get(MConstants.JOB_STATUS, "")
        if job_status == FinalJobStatus.HALTED:
            raise RunTimedout()
        if job_status not in FinalJobStatus.get_statuses():
            # The job is not completed yet
            return None

        # now we no longer use output_json

        progress_json = fetcher.get_treeview_json(link)
        if progress_json is None:
            raise Util.ImplementationError("Could not get progress object")
        top_level_rules = get_top_level_rules(progress_json)
        if top_level_rules is None:
            raise Util.BadMutationError("Could not get tree view object")
        rule_results = []

        for rule in top_level_rules:
            # as long as we have children, we need to keep looking.
            # we prioritize failures, then unknown, then timeout, then sanity_fail, and only all success is a success
            if MConstants.CHILDREN not in rule:
                raise Util.ImplementationError(f"Bad format for a rule {rule}")

            if MConstants.NAME not in rule:
                raise Util.ImplementationError(f"Bad format for a rule {rule}")

            leaf_statuses: List[str] = []
            rec_collect_statuses_children(rule, leaf_statuses)
            name = rule[MConstants.NAME]
            if len(leaf_statuses) == 0:
                raise Util.ImplementationError("Got no rule results")
            elif any([s == MutationTestRuleStatus.FAIL for s in leaf_statuses]):
                rule_results.append(RuleResult(name, MutationTestRuleStatus.FAIL.value))
            elif any([s == MutationTestRuleStatus.UNKNOWN for s in leaf_statuses]):
                rule_results.append(RuleResult(name, MutationTestRuleStatus.UNKNOWN.value))
            elif any([s == MutationTestRuleStatus.TIMEOUT for s in leaf_statuses]):
                rule_results.append(RuleResult(name, MutationTestRuleStatus.TIMEOUT.value))
            elif any([s == MutationTestRuleStatus.SANITY_FAIL for s in leaf_statuses]):
                rule_results.append(RuleResult(name, MutationTestRuleStatus.SANITY_FAIL.value))
            elif not all([s == MutationTestRuleStatus.SUCCESS for s in leaf_statuses]):
                raise Util.ImplementationError("Encountered a new unknown status which isn't FAIL, UNKNOWN, TIMEOUT, "
                                               f"SANITY_FAIL, or SUCCESS. statuses: {leaf_statuses}")
            else:
                rule_results.append(RuleResult(name, MutationTestRuleStatus.SUCCESS.value))

        return rule_results

    def print_final_report_url_msg(self, url: str, mutation_id: str, anonymous_key: str) -> None:
        final_url = f"{url}?id={mutation_id}&{MConstants.ANONYMOUS_KEY}={anonymous_key}"
        Console().print(f"\n\n[bold orange4]Final mutation report is available at {Util.print_rich_link(final_url)}\n")

        if self.dump_link:
            with open(self.dump_link, "w") as file:
                file.write(final_url)

    def read_ui_file(self) -> Any:
        if not self.ui_out:
            raise Util.ImplementationError("read_ui_file: 'ui_out' file not defined")
        if self.ui_out.exists():
            try:
                with self.ui_out.open('r') as ui_out_json:
                    return json.load(ui_out_json)
            except Exception:
                raise Util.ImplementationError(f"Failed to read {self.ui_out}")
        else:
            raise Util.ImplementationError(f"Couldn't locate the output file ({self.ui_out})")

    def poll_collect(self) -> None:
        SECONDS_IN_MINUTE = 60
        assert self.poll_timeout, "poll_collect: self.poll_timeout"
        poll_timeout_seconds = self.poll_timeout * SECONDS_IN_MINUTE
        start = time.time()
        duration = 0  # seconds
        attempt_number = 1
        retry = 15
        ready = False
        while duration < poll_timeout_seconds:
            mutation_logger.info(f"-------> Trying to poll results... attempt #{attempt_number}")
            ready = self.collect()
            if not ready:
                mutation_logger.info(f"-------> Results are still not ready, trying in {retry} seconds")
                attempt_number += 1
                time.sleep(retry)
            else:
                self.print_notification_msg()
                return
            duration = int(time.time() - start)

        if not ready:
            raise Util.CertoraUserInputError(f"Could not get results after {attempt_number} attempts.")

    # all keys in prover_context must exist as attribute of certoraRun
    def check_prover_context(self) -> None:
        attributes = Attrs.SorobanProverAttributes.attribute_list() if self.is_soroban_run() \
            else Attrs.EvmProverAttributes.attribute_list()
        prover_attrs = [attr.get_conf_key() for attr in attributes]
        for key in vars(self.prover_context).keys():
            if key not in prover_attrs:
                raise Util.CertoraUserInputError(f"{key} not a valid attribute in a conf file")

    def get_args(self, args_list: List[str]) -> None:
        attr_dict = MutAttrs.get_args(args_list)
        for key, value in attr_dict.items():
            setattr(self, key, value)

    def read_conf_file(self) -> None:

        def set_conf_file() -> None:
            conf = getattr(self, 'conf', None)
            conf_no_flag = getattr(self, 'conf_no_flag', None)
            if conf and conf_no_flag:
                if conf != conf_no_flag:
                    raise Util.CertoraUserInputError(f"conf  was set to both {conf} and {conf_no_flag}")
                else:
                    raise Util.CertoraUserInputError(f"conf file {conf} was set twice, with and without a flag")
            if conf_no_flag:
                self.conf = conf_no_flag
            try:
                delattr(self, 'conf_no_flag')
            except Exception:
                pass

        set_conf_file()
        if not self.conf:
            if getattr(self, 'collect_mode', False):
                return
            else:
                raise Util.CertoraUserInputError("No conf file was set")

        Vf.validate_readable_file(str(self.conf))

        with open(self.conf, 'r') as conf_file:
            try:
                self.prover_context = CertoraContext(**Util.read_conf_file(conf_file))
                self.check_prover_context()
            except Exception as e:
                raise Util.CertoraUserInputError(f"Failed to parse {self.conf} as JSON", e)

        try:
            mutation_obj = self.prover_context.mutations
        except KeyError:
            raise Util.CertoraUserInputError(f"Missing 'Mutations' object in conf file {conf_file}")

        mutation_attrs = [attr.get_conf_key() for attr in MutAttrs.MutateAttributes.attribute_list()]

        for option in mutation_obj:
            if option not in mutation_attrs:
                raise Util.CertoraUserInputError(f"Unknown key, {option}, under 'Mutations' in the conf file ")

            val = getattr(self, option)
            if val is None or val is False or val == []:
                setattr(self, option, mutation_obj[option])

    def get_common_solc_flags(self) -> List[str]:
        common_flags = []

        if hasattr(self.prover_context, MConstants.SOLC_OPTIMIZE) or \
           hasattr(self.prover_context, MConstants.SOLC_OPTIMIZE_MAP):
            common_flags.extend(['--optimize'])

        if hasattr(self.prover_context, MConstants.SOLC_ALLOW_PATH):
            common_flags.extend(['--allow-paths', '/'])

        if hasattr(self.prover_context, MConstants.SOLC_EVM_VERSION):
            common_flags.extend(['--evm-version', self.prover_context.solc_evm_version])

        if hasattr(self.prover_context, MConstants.PACKAGES):
            common_flags.extend(self.prover_context.packages)

        return common_flags

    def compile_manual_mutants(self, mutant: Mutant, trg_dir: Path) -> None:

        if not self.manual_mutants:
            return

        solc = self.get_solc_version(Path(mutant.original_filename))
        if not solc:
            raise Util.CertoraUserInputError(f"Unable to find a compiler for manual mutant {mutant.original_filename}")

        via_ir_flag = []
        if getattr(self.prover_context, MConstants.SOLC_VIA_IR, '') or \
                getattr(self.prover_context, MConstants.SOLC_EXPERIMENTAL_VIA_IR, ''):
            via_ir_flag = [Util.get_ir_flag(solc)]

        args = [solc] + self.common_solc_flags + via_ir_flag + [mutant.original_filename]
        if self.test == str(Util.TestValue.CHECK_MANUAL_COMPILATION):
            raise Util.TestResultsReady(' '.join(args))
        with Util.change_working_directory(find_cwd(trg_dir)):
            result = subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        if result.returncode:
            mutation_logger.debug(f"mutation compilation failed: cmd: {' '.join(args)}\n cwd: {os.getcwd()}")
            raise Util.CertoraUserInputError(f"mutation file {mutant.filename} failed to compile\n\n"
                                             f"{result.stderr.decode()}")


def rec_collect_statuses_children(rule: Dict[str, Any], statuses: List[str]) -> None:
    statuses.append(convert_to_mutation_testing_status(rule[MConstants.STATUS]))
    for child in rule[MConstants.CHILDREN]:
        rec_collect_statuses_children(child, statuses)


def get_file_url_from_orig_url(url: str, file: str) -> str:
    parsed_url = urllib3.util.parse_url(url)
    file_url = f"{parsed_url.scheme}://{parsed_url.hostname}{parsed_url.path}"
    # ensure there is a single slash
    if not file_url.endswith("/"):
        file_url += "/"
    file_url += f"{file}?{parsed_url.query}"
    return file_url


def get_top_level_rules(progress_json: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
    if MConstants.VERIFICATION_PROGRESS not in progress_json:
        mutation_logger.debug(f"Could not find {MConstants.VERIFICATION_PROGRESS} in progress {progress_json}")
        return None
    # verification progress holds a string which is a json encoding of the latest tree view file
    tree_view_json = json.loads(progress_json[MConstants.VERIFICATION_PROGRESS])
    if MConstants.RULES not in tree_view_json:
        mutation_logger.debug(f"Could not find rules in tree view file {tree_view_json}")
        return None
    return tree_view_json[MConstants.RULES]


certora_key = os.getenv(KEY_ENV_VAR, '')
auth_headers = {"Authorization": f"Bearer {certora_key}", "Content-Type": "application/json"}
default_cookies = {str(MConstants.CERTORA_KEY): certora_key}


def download_report_file(report_url: str, filename: str) -> bool:
    """
    Copy a file from the "Reports" folder to the current working dir with the same file name
    url to specific report file is of the form https://<server>/output/NNN/MMM/<filename>?anonymousKey=PPPPP
    (i.e. in the report url add the filename before ?anonymousKey)
    False is returned if the file was not found or if the download did not succeed
    """
    time.sleep(5)  # make sure the file was written
    url = report_url.replace('?', f'/{filename}?')
    try:
        response = requests.get(url)
    except requests.exceptions.RequestException as e:
        logging.debug(f"request for {url} failed: {e}")
        return False
    if response.status_code == 200:
        with open(filename, 'wb') as file:
            file.write(response.content)
        logging.debug(f"{filename} downloaded from {report_url}")
    elif response.status_code == 404:  # file not found is not an error
        mutation_logger.debug(f"{filename} not found in {report_url}")
        return False
    else:
        mutation_logger.debug(f"Failed to download {filename} from {report_url}. Status code: {response.status_code}")
        return False
    return True
