# Copyright 2013: Mirantis Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import collections
import json
import os
import re
import sys
import time

import jinja2
import jinja2.meta
import requests
from requests.packages import urllib3

from rally import consts
from rally import exceptions
from rally.common import cfg
from rally.common import logging
from rally.common import objects
from rally.common import opts
from rally.common import utils
from rally.common import version as rally_version
from rally.common.plugin import discover
from rally.task import engine
from rally.task import exporter as texporter
from rally.task import task_cfg
from rally.verification import context as vcontext
from rally.verification import manager as vmanager
from rally.verification import reporter as vreporter


CONF = cfg.CONF
LOG = logging.getLogger(__name__)
API_REQUEST_PREFIX = "/api"


opts.register()


class APIGroup:
    def __init__(self, api):
        """Initialize API group.

        :param api: an instance of rally.api.API object
        """
        self.api = api


class _Deployment(APIGroup):
    def _create(self, config, name):
        """Create a deployment.

        :param config: a dict with deployment configuration
        :param name: a str represents a name of the deployment
        :returns: Deployment object
        """

        # NOTE(andreykurilin): the following transformation is a preparatory
        #   step for further refactoring (it will be done soon).
        print_warning = False

        extras = {}
        if "type" in config:
            if config["type"] != "ExistingCloud":
                raise exceptions.RallyException(
                    "You are using deployment type which doesn't exist. Please"
                    " check the latest documentation and fix deployment "
                    "config."
                )

            config = config["creds"]
            extras = config.get("extra", {})
            print_warning = True

        try:
            deployment = objects.Deployment(
                name=name, config=config, extras=extras
            )
        except exceptions.DBRecordExists:
            if logging.is_debug():
                LOG.exception("Deployment with such name exists")
            raise

        if print_warning:
            new_conf = deployment.env_obj.spec
            LOG.warning(
                "The used config schema is deprecated since Rally 0.10.0. "
                "The new one is much simpler, try it now:\n%s"
                % json.dumps(new_conf, indent=4)
            )

        return deployment

    def create(self, config, name):
        return self._create(config, name).to_dict()

    def destroy(self, deployment):
        """Destroy the deployment.

        :param deployment: UUID or name of the deployment
        """

        deploy = objects.Deployment.get(deployment)

        deploy.env_obj.destroy(skip_cleanup=True)
        deploy.env_obj.delete()

    def recreate(self, deployment, config=None):
        """Performs a cleanup and then makes a deployment again.

        :param deployment: UUID or name of the deployment
        :param config: an optional dict with deployment config to update before
                       redeploy
        """
        raise exceptions.RallyException(
            "Sorry, but recreate method of deployments is temporary disabled."
        )

    def _get(self, deployment):
        """Get the deployment.

        :param deployment: UUID or name of the deployment
        :returns: Deployment instance
        """
        return objects.Deployment.get(deployment)

    def get(self, deployment):
        return self._get(deployment).to_dict()

    def list(self, status=None, parent_uuid=None, name=None):
        """Get the deployments list.

        :returns: Deployment list
        """
        return [
            deployment.to_dict()
            for deployment in objects.Deployment.list(
                status, parent_uuid, name
            )
        ]

    def check(self, deployment):
        """Check keystone authentication and list all available services.

        :param deployment: UUID of deployment
        :returns: Service list
        """
        env = self._get(deployment).env_obj

        result = {}

        for p, res in env.check_health().items():
            name = "openstack" if p == "existing@openstack" else p
            if not res["available"]:
                # NOTE(andreykurilin): the old behavior supports 2 keys
                #   for storing errors: admin_error and user_error.
                #   Since admin/users is not mandatory thing for new design
                #   of Platforms, let's put platform error to "admin_error"
                key = "admin_error"
                if name == "openstack":
                    if res["message"].startswith("Bad user creds"):
                        key = "user_error"

                if "traceback" in res:
                    # NOTE(andreykurilin): the last not null line in traceback
                    #   includes Exception cls with a message. By parsing it,
                    #   we can get etype.
                    trace = res["traceback"].split("\n")
                    last_line = [line for line in trace if line][-1]
                    etype, _msg = last_line.split(":", 1)
                else:
                    etype = "n/a"
                result[name] = [
                    {
                        key: {
                            "etype": etype,
                            "msg": res["message"],
                            "trace": res.get("traceback", "n/a"),
                        },
                        "services": [],
                    }
                ]
            else:
                if name == "openstack":
                    services = env.get_info()[p]["info"]["services"]
                    # backward compatibility
                    for s in services:
                        if "name" not in s:
                            s["name"] = "__unknown__"

                    result[name] = [{"services": services}]
                else:
                    # NOTE(andreykurilin): the info method of platforms allows
                    #   to return whatever single platform wants, i.e
                    #   Platform X can return just a version and no services
                    #   at all. Checking for 'services' key there is not a
                    #   solution, since the value of it can have the format
                    #   which differs from openstack-like (the old design of
                    #   Deployment component)
                    result[name] = [{"services": []}]

        return result


class _Task(APIGroup):
    TASK_SCHEMA = objects.task.TASK_SCHEMA

    def list(self, **filters):
        return [task.to_dict() for task in objects.Task.list(**filters)]

    def get(self, task_id, detailed=False):
        """Get task data

        :param task_id: Task UUID
        :param detailed: whether return detailed information(including
            subtasks and workloads) or not.
        """
        return objects.Task.get(task_id, detailed=detailed).to_dict()

    # TODO(andreykurilin): move it to some kind of utils
    def render_template(self, task_template, template_dir="./", **kwargs):
        """Render jinja2 task template to Rally input task.

        :param task_template: String that contains template
        :param template_dir: The path of directory contain template files
        :param kwargs: Dict with template arguments
        :returns: rendered template str
        """

        def is_really_missing(mis, task_template):
            # NOTE(boris-42): Removing variables that have default values from
            #                 missing. Construction that won't be properly
            #                 checked is {% set x = x or 1}
            if re.search(
                mis.join([r"{%\s*set\s+", r"\s*=\s*", r"[^\w]+"]),
                task_template,
            ):
                return False
            # NOTE(jlk): Also check for a default filter which can show up as
            #            a missing variable
            if re.search(mis + r"\s*\|\s*default\(", task_template):
                return False
            return True

        # NOTE(boris-42): We have to import builtins to get the full list of
        #                 builtin functions (e.g. range()). Unfortunately,
        #                 __builtins__ doesn't return them (when it is not
        #                 main module)
        import builtins

        env = jinja2.Environment(
            loader=jinja2.FileSystemLoader(template_dir, encoding="utf8")
        )
        env.globals.update(self.create_template_functions())
        ast = env.parse(task_template)
        # NOTE(Julia Varigina):
        # Bug in jinja2.meta.find_undeclared_variables
        #
        # The method shows inconsistent behavior:
        # it does not return undeclared variables that appear
        # in included templates only (via {%- include "some_template.yaml"-%})
        # and in the same time is declared in jinja2.Environment.globals.
        #
        # This is different for undeclared variables that appear directly
        # in task_template. The method jinja2.meta.find_undeclared_variables
        # returns an undeclared variable that is used in task_template
        # and is set in jinja2.Environment.globals.
        #
        # Despite this bug, jinja resolves values
        # declared in jinja2.Environment.globals for both types of undeclared
        # variables and successfully renders templates in both cases.
        required_kwargs = jinja2.meta.find_undeclared_variables(ast)
        missing = (
            set(required_kwargs)
            - set(kwargs)
            - set(dir(builtins))
            - set(env.globals)
        )
        real_missing = [
            mis for mis in missing if is_really_missing(mis, task_template)
        ]
        if real_missing:
            multi_msg = "Please specify next template task arguments: %s"
            single_msg = "Please specify template task argument: %s"

            raise TypeError(
                (len(real_missing) > 1 and multi_msg or single_msg)
                % ", ".join(real_missing)
            )

        render_template = env.from_string(task_template).render(**kwargs)
        return render_template

    def create_template_functions(self):

        def template_min(int1, int2):
            return min(int1, int2)

        def template_max(int1, int2):
            return max(int1, int2)

        def template_round(float1):
            return int(round(float1))

        def template_ceil(float1):
            import math

            return int(math.ceil(float1))

        return {
            "min": template_min,
            "max": template_max,
            "ceil": template_ceil,
            "round": template_round,
        }

    def create(self, deployment, tags=None):
        """Create a task without starting it.

        Task is a list of subtasks that are called one by one, results of
        execution are stored into DB.

        Every subtask is sort of test case which is created by combination
        of scenario, runner, contexts, sla and hoooks plugins.

        :param deployment: UUID or name of the deployment
        :param tags: a list of tags for this task
        :returns: Task object
        """
        deployment = objects.Deployment.get(deployment)
        if deployment["status"] != consts.DeployStatus.DEPLOY_FINISHED:
            raise exceptions.DeploymentNotFinishedStatus(
                name=deployment["name"],
                uuid=deployment["uuid"],
                status=deployment["status"],
            )

        return objects.Task(env_uuid=deployment["uuid"], tags=tags).to_dict()

    def validate(self, deployment, config, task_instance=None, task=None):
        """Validate a task config against specified deployment.

        :param deployment: UUID or name of the deployment (will be ignored in
            case of transmitting task_instance or task arguments)
        :param config: a dict with a task configuration
        :param task_instance: DEPRECATED. Use "task" argument to transmit task
            uuid instead
        """
        if task_instance is not None:
            LOG.warning(
                "Transmitting task object in `task validate` is "
                "deprecated since Rally 0.10. To use pre-created "
                "task, transmit task UUID instead via `task` "
                "argument."
            )
            task = objects.Task.get(task_instance["uuid"])
            deployment = task["deployment_uuid"]
        elif task:
            task = objects.Task.get(task)
            deployment = task["deployment_uuid"]
        else:
            task = objects.Task(env_uuid=deployment, temporary=True)
        deployment = objects.Deployment.get(deployment)

        try:
            config = task_cfg.TaskConfig(config)
        except exceptions.InvalidTaskException:
            # it is a proper formed exception, nothing to do
            raise
        except Exception as e:
            if logging.is_debug():
                LOG.exception("Unexpected error had happened")
            raise exceptions.InvalidTaskException(str(e))

        engine.TaskEngine(config, task, deployment.env_obj).validate()

    def start(self, deployment, config, task=None, abort_on_sla_failure=False):
        """Validate and start a task.

        Task is a list of subtasks that are called one by one, results of
        execution are stored in DB.

        :param deployment: UUID or name of the deployment (will be ignored in
            case of transmitting existing task)
        :param config: a dict with a task configuration
        :param task: Task UUID to use pre-created task. If None, new task will
            be created
        :param abort_on_sla_failure: If set to True, the task execution is
                                     stop when any of SLA checks fails
        """
        if task and isinstance(task, objects.Task):
            LOG.warning(
                "Transmitting task object in `task start` is "
                "deprecated since Rally 0.10. To use pre-created "
                "task, transmit task UUID instead."
            )
            if task.is_temporary:
                raise ValueError(
                    "Unable to run a temporary task. Please check your code."
                )
            task = objects.Task.get(task["uuid"])
        elif task is not None:
            task = objects.Task.get(task)

        if task is not None:
            deployment = task["deployment_uuid"]

        deployment = objects.Deployment.get(deployment)
        if deployment["status"] != consts.DeployStatus.DEPLOY_FINISHED:
            raise exceptions.DeploymentNotFinishedStatus(
                name=deployment["name"],
                uuid=deployment["uuid"],
                status=deployment["status"],
            )

        try:
            config = task_cfg.TaskConfig(config)
        except exceptions.InvalidTaskException:
            # it is a proper formed exception, nothing to do
            raise
        except Exception as e:
            if logging.is_debug():
                LOG.exception("Unexpected error had happened")
            raise exceptions.InvalidTaskException(str(e))

        if task is None:
            task = objects.Task(
                deployment_uuid=deployment["uuid"],
                title=config.title,
                description=config.description,
            )

        task_engine = engine.TaskEngine(
            config,
            task,
            deployment.env_obj,
            abort_on_sla_failure=abort_on_sla_failure,
        )

        task_engine.validate()

        LOG.info("Task %s input file is valid." % task["uuid"])
        LOG.info(
            "Run Task %s against Deployment %s"
            % (task["uuid"], deployment["uuid"])
        )

        task_engine.run()

        return task["uuid"], task.get_status(task["uuid"])

    def abort(self, task_uuid, soft=False, wait=False, **kwargs):
        """Abort running task.

        :param task_uuid: The UUID of the task
        :type task_uuid: str
        :param soft: If set to True, task should be aborted after execution of
                     current scenario, otherwise as soon as possible before
                     all the scenario iterations finish [Default: False]
        :type soft: bool
        :param wait: wait until task stops [Default: False]
        :type wait: bool
        """
        if kwargs:
            if len(kwargs) != 1 or "async" not in kwargs:
                raise TypeError(
                    "API method task.abort accept only one "
                    "argument 'async' (which is deprecated in "
                    "favor of 'wait')."
                )
            elif "async" in kwargs:
                LOG.warning(
                    "The argument 'async' of API method task.abort is "
                    "deprecated since Rally 1.1.0 in favor of new "
                    "argument 'wait' which doesn't conflict with a "
                    "reserved keywords in python 3.7"
                )
                wait = not kwargs["async"]

        if wait:
            current_status = objects.Task.get_status(task_uuid)
            if current_status in objects.Task.NOT_IMPLEMENTED_STAGES_FOR_ABORT:
                LOG.info(
                    "Task status is '%s' waiting until it became 'running'"
                    % current_status
                )
                while (
                    current_status
                    in objects.Task.NOT_IMPLEMENTED_STAGES_FOR_ABORT
                ):
                    time.sleep(1)
                    current_status = objects.Task.get_status(task_uuid)

        objects.Task.get(task_uuid).abort(soft=soft)

        if wait:
            LOG.info("Waiting until the task stops.")
            finished_stages = [
                consts.TaskStatus.ABORTED,
                consts.TaskStatus.FINISHED,
                consts.TaskStatus.CRASHED,
            ]
            while objects.Task.get_status(task_uuid) not in finished_stages:
                time.sleep(1)

    def delete(self, task_uuid, force=False):
        """Deletes all task data from database.

        :param task_uuid: The UUID of the task
        :param force: If set to True, then delete the task despite to the
                      status
        :raises DBConflict: when the status of the task is not
                            in FINISHED, FAILED or ABORTED and
                            the force argument is not True
        :raises DBRecordNotFound: when task doesn't exist
        """
        if force:
            objects.Task.delete_by_uuid(task_uuid, status=None)
        elif objects.Task.get_status(task_uuid) in (
            consts.TaskStatus.ABORTED,
            consts.TaskStatus.FINISHED,
            consts.TaskStatus.CRASHED,
        ):
            objects.Task.delete_by_uuid(task_uuid, status=None)
        else:
            objects.Task.delete_by_uuid(
                task_uuid, status=consts.TaskStatus.FINISHED
            )

    def import_results(self, deployment, task_results, tags=None):
        """Import json results of a task into rally database"""
        deployment = objects.Deployment.get(deployment)
        if deployment["status"] != consts.DeployStatus.DEPLOY_FINISHED:
            raise exceptions.DeploymentNotFinishedStatus(
                name=deployment["name"],
                uuid=deployment["uuid"],
                status=deployment["status"],
            )

        task_inst = objects.Task(env_uuid=deployment["uuid"], tags=tags)
        task_inst.update_status(consts.TaskStatus.RUNNING)
        for subtask in task_results["subtasks"]:
            subtask_obj = task_inst.add_subtask(title=subtask.get("title"))
            for workload in subtask["workloads"]:
                for data in workload["data"]:
                    if not task_inst.result_has_valid_schema(data):
                        raise exceptions.RallyException(
                            "Task %s is trying to import "
                            "results in wrong format" % task_inst["uuid"]
                        )

                workload_obj = subtask_obj.add_workload(
                    name=workload["name"],
                    description=workload["description"],
                    position=workload["position"],
                    runner=workload["runner"],
                    runner_type=workload["runner_type"],
                    contexts=workload["contexts"],
                    hooks=workload["hooks"],
                    sla=workload["sla"],
                    args=workload["args"],
                )

                chunk_size = CONF.raw_result_chunk_size
                workload_data_count = 0
                while len(workload["data"]) > chunk_size:
                    results_chunk = workload["data"][:chunk_size]
                    workload["data"] = workload["data"][chunk_size:]
                    results_chunk.sort(key=lambda x: x["timestamp"])
                    workload_obj.add_workload_data(
                        workload_data_count, {"raw": results_chunk}
                    )
                    workload_data_count += 1

                workload_obj.add_workload_data(
                    workload_data_count, {"raw": workload["data"]}
                )
                workload_obj.set_results(
                    sla_results=workload["sla_results"].get("sla"),
                    hooks_results=workload["hooks"],
                    start_time=workload["start_time"],
                    full_duration=workload["full_duration"],
                    load_duration=workload["load_duration"],
                    contexts_results=workload["contexts_results"],
                )
            subtask_obj.update_status(consts.SubtaskStatus.FINISHED)
        task_inst.update_status(consts.SubtaskStatus.FINISHED)

        LOG.info("Task results have been successfully imported.")

        return task_inst.to_dict()

    def export(self, tasks, output_type, output_dest=None):
        """Generate a report for a task or a few tasks.

        :param tasks: List of tasks UUIDs or tasks results
        :param output_type: Plugin name of task exporter
        :param output_dest: Destination for task report
        """

        tasks_results: list[dict] = []
        for task in tasks or []:
            if isinstance(task, dict):
                tasks_results.append(task)
            else:
                tasks_results.append(self.get(task_id=task, detailed=True))

        errors = texporter.TaskExporter.validate(
            output_type,
            context={},
            config={},
            # wrap destination to a dict to allow extending options in future
            plugin_cfg={"destination": output_dest},
            vtype="syntax",
        )
        if errors:
            raise exceptions.ValidationError("\n".join(errors))

        reporter_cls = texporter.TaskExporter.get(output_type)

        tasks_uuids = "', '".join(task["uuid"] for task in tasks_results)
        LOG.info(
            f"Building '{output_type}' report for the following task(s): "
            f"'{tasks_uuids}'."
        )
        result = texporter.TaskExporter.make(
            reporter_cls, tasks_results, output_dest, api=self.api
        )
        LOG.info("The report has been successfully built.")
        return result


class _Verifier(APIGroup):
    def list_plugins(self, platform=None):
        """List all plugins for verifiers management.

        :param platform: Verifier plugin platform
        """
        return [
            {
                "name": p.get_name(),
                "platform": p.get_platform(),
                "description": p.get_info()["title"],
                "location": "%s.%s" % (p.__module__, p.__name__),
            }
            for p in vmanager.VerifierManager.get_all(platform=platform)
        ]

    def create(
        self,
        name,
        vtype,
        platform=None,
        source=None,
        version=None,
        system_wide=False,
        extra_settings=None,
    ):
        """Create a verifier.

        :param name: Verifier name
        :param vtype: Verifier plugin name
        :param platform: Verifier plugin platform. Should be specified when
                          there are two verifier plugins with equal names but
                          in different platforms
        :param source: Path or URL to the repo to clone verifier from
        :param version: Branch, tag or commit ID to checkout before
                        verifier installation
        :param system_wide: Whether or not to use the system-wide environment
                            for verifier instead of a virtual environment
        :param extra_settings: Extra installation settings for verifier
        """
        # check that the specified verifier type exists
        vmanager.VerifierManager.get(vtype, platform=platform)

        LOG.info("Creating verifier '%s'." % name)

        try:
            verifier = self._get(name)
        except exceptions.DBRecordNotFound:
            verifier = objects.Verifier.create(
                name=name,
                source=source,
                system_wide=system_wide,
                version=version,
                vtype=vtype,
                platform=platform,
                extra_settings=extra_settings,
            )
        else:
            raise exceptions.RallyException(
                "Verifier with name '%s' already exists! Please, specify "
                "another name for verifier and try again." % verifier.name
            )

        properties = {}
        properties["platform"] = platform or verifier.manager.get_platform()

        default_source = verifier.manager._meta_get("default_repo")
        if not source and default_source:
            properties["source"] = default_source

        if properties:
            verifier.update_properties(**properties)

        verifier.update_status(consts.VerifierStatus.INSTALLING)
        try:
            verifier.manager.install()
        except Exception:
            verifier.update_status(consts.VerifierStatus.FAILED)
            raise
        verifier.update_status(consts.VerifierStatus.INSTALLED)

        LOG.info("Verifier %s has been successfully created!" % verifier)

        return verifier.uuid

    def _get(self, verifier_id):
        """Get a verifier.

        :param verifier_id: Verifier name or UUID
        """
        return objects.Verifier.get(verifier_id)

    def get(self, verifier_id):
        return self._get(verifier_id).to_dict()

    def _list(self, status=None):
        """List all verifiers.

        :param status: Status to filter verifiers by
        """
        return objects.Verifier.list(status)

    def list(self, status=None):
        return [item.to_dict() for item in self._list(status)]

    def delete(self, verifier_id, deployment_id=None, force=False):
        """Delete a verifier.

        :param verifier_id: Verifier name or UUID
        :param deployment_id: Deployment name or UUID. If specified,
                              only the deployment-specific data will be deleted
                              for verifier
        :param force: Delete all stored verifier verifications.
                      If deployment_id specified, only verifications of this
                      deployment will be deleted
        """
        verifier = self._get(verifier_id)
        verifications = self.api.verification.list(
            verifier_id=verifier_id, deployment_id=deployment_id
        )
        if verifications:
            d_msg = (
                (" for deployment '%s'" % deployment_id)
                if deployment_id
                else ""
            )
            if force:
                LOG.info(
                    "Deleting all verifications created by verifier %s%s."
                    % (verifier, d_msg)
                )
                for verification in verifications:
                    self.api.verification.delete(
                        verification_uuid=verification["uuid"]
                    )
            else:
                raise exceptions.RallyException(
                    f"Failed to delete verifier {verifier} because there are "
                    f"stored verifier verifications{d_msg}! Please, make sure "
                    "that they are not important to you. Use 'force' flag if "
                    f"you would like to delete verifications{d_msg} as well."
                )

        if deployment_id:
            LOG.info(
                "Deleting deployment-specific data for verifier %s." % verifier
            )
            verifier.set_env(deployment_id)
            verifier.manager.uninstall()
            LOG.info("Deployment-specific data has been successfully deleted!")
        else:
            LOG.info("Deleting verifier %s." % verifier)
            verifier.manager.uninstall(full=True)
            objects.Verifier.delete(verifier_id)
            LOG.info("Verifier has been successfully deleted!")

    def update(
        self, verifier_id, system_wide=None, version=None, update_venv=False
    ):
        """Update a verifier.

        :param verifier_id: Verifier name or UUID
        :param system_wide: Switch to using the system-wide environment
        :param version: Branch, tag or commit ID to checkout
        :param update_venv: Update the virtual environment for verifier
        """
        if system_wide is None and version is None and not update_venv:
            # nothing to update
            raise exceptions.RallyException(
                "At least one of the following parameters should be "
                "specified: 'system_wide', 'version', 'update_venv'."
            )

        verifier = self._get(verifier_id)
        LOG.info("Updating verifier %s." % verifier)

        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to update verifier %s because verifier is in '%s' "
                "status, but should be in '%s'."
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        system_wide_in_use = system_wide or (
            system_wide is None and verifier.system_wide
        )
        if update_venv and system_wide_in_use:
            raise exceptions.RallyException(
                "It is impossible to update the virtual environment for "
                "verifier %s when it uses the system-wide environment."
                % verifier
            )

        # store original status to set it again after updating or rollback
        original_status = verifier.status
        verifier.update_status(consts.VerifierStatus.UPDATING)

        properties = {}  # store new verifier properties to update old ones

        sw_is_checked = False

        if version:
            properties["version"] = version

            backup = utils.BackupHelper()
            rollback_msg = (
                "Failed to update verifier %s. It has been "
                "rollbacked to the previous state." % verifier
            )
            backup.add_rollback_action(LOG.info, rollback_msg)
            backup.add_rollback_action(verifier.update_status, original_status)
            with backup(verifier.manager.repo_dir):
                verifier.manager.checkout(version)

            if system_wide_in_use:
                verifier.manager.check_system_wide()
                sw_is_checked = True

        if system_wide is not None:
            if system_wide == verifier.system_wide:
                LOG.info(
                    "Verifier %s is already switched to system_wide=%s. "
                    "Nothing will be changed."
                    % (verifier, verifier.system_wide)
                )
            else:
                properties["system_wide"] = system_wide
                if not system_wide:
                    update_venv = True  # we need to install a virtual env
                else:
                    # NOTE(andreykurilin): should we remove previously created
                    #   virtual environment?!
                    if not sw_is_checked:
                        verifier.manager.check_system_wide()

        if update_venv:
            backup = utils.BackupHelper()
            rollback_msg = (
                "Failed to update the virtual environment for "
                "verifier %s. It has been rollbacked to the "
                "previous state." % verifier
            )
            backup.add_rollback_action(LOG.info, rollback_msg)
            backup.add_rollback_action(verifier.update_status, original_status)
            with backup(verifier.manager.venv_dir):
                verifier.manager.install_venv()

        properties["status"] = original_status  # change verifier status back
        verifier.update_properties(**properties)

        LOG.info("Verifier %s has been successfully updated!" % verifier)

        return verifier.uuid

    def configure(
        self, verifier, deployment_id, extra_options=None, reconfigure=False
    ):
        """Configure a verifier.

        :param verifier: Verifier object or (name or UUID)
        :param deployment_id: Deployment name or UUID
        :param extra_options: Extend verifier configuration with extra options
        :param reconfigure: Reconfigure verifier
        """
        if not isinstance(verifier, objects.Verifier):
            verifier = self._get(verifier)
        verifier.set_env(deployment_id)
        LOG.info(
            "Configuring verifier %s for deployment '%s' (UUID=%s)."
            % (
                verifier,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
            )
        )

        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to configure verifier %s for deployment '%s' "
                "(UUID=%s) because verifier is in '%s' status, but should be "
                "in '%s'."
                % (
                    verifier,
                    verifier.deployment["name"],
                    verifier.deployment["uuid"],
                    verifier.status,
                    consts.VerifierStatus.INSTALLED,
                )
            )

        msg = (
            "Verifier %s has been successfully configured for deployment "
            "'%s' (UUID=%s)!"
            % (
                verifier,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
            )
        )
        vm = verifier.manager
        if vm.is_configured():
            LOG.info("Verifier is already configured!")
            if not reconfigure:
                if not extra_options:
                    return vm.get_configuration()
                else:
                    # Just add extra options to the config file.
                    if logging.is_debug():
                        LOG.debug(
                            "Adding the following extra options: %s "
                            "to verifier configuration." % extra_options
                        )
                    else:
                        LOG.info(
                            "Adding extra options to verifier configuration."
                        )
                    vm.extend_configuration(extra_options)
                    LOG.info(msg)
                    return vm.get_configuration()

            LOG.info("Reconfiguring verifier.")

        raw_config = vm.configure(extra_options=extra_options)

        LOG.info(msg)

        return raw_config

    def override_configuration(
        self, verifier_id, deployment_id, new_configuration
    ):
        """Override verifier configuration (e.g., rewrite the config file).

        :param verifier_id: Verifier name or UUID
        :param deployment_id: Deployment name or UUID
        :param new_configuration: New configuration for verifier
        """
        verifier = self._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to override verifier configuration for deployment "
                "'%s' (UUID=%s) because verifier %s is in '%s' status, but "
                "should be in '%s'."
                % (
                    verifier.deployment["name"],
                    verifier.deployment["uuid"],
                    verifier,
                    verifier.status,
                    consts.VerifierStatus.INSTALLED,
                )
            )

        verifier.set_env(deployment_id)
        LOG.info(
            "Overriding configuration of verifier %s for deployment '%s' "
            "(UUID=%s)."
            % (
                verifier,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
            )
        )
        verifier.manager.override_configuration(new_configuration)
        LOG.info(
            "Configuration of verifier %s has been successfully "
            "overridden for deployment '%s' (UUID=%s)!"
            % (
                verifier,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
            )
        )

    def list_tests(self, verifier_id, pattern=""):
        """List all verifier tests.

        :param verifier_id: Verifier name or UUID
        :param pattern: Pattern which will be used for matching
        """
        verifier = self._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to list verifier tests because verifier %s is in '%s' "
                "status, but should be in '%s'."
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        if pattern:
            verifier.manager.validate_args({"pattern": pattern})

        return verifier.manager.list_tests(pattern)

    def add_extension(
        self, verifier_id, source, version=None, extra_settings=None
    ):
        """Add a verifier extension.

        :param verifier_id: Verifier name or UUID
        :param source: Path or URL to the repo to clone verifier extension from
        :param version: Branch, tag or commit ID to checkout before
                        installation of the verifier extension
        :param extra_settings: Extra installation settings for verifier
                               extension
        """
        verifier = self._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to add verifier extension because verifier %s "
                "is in '%s' status, but should be in '%s'."
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        LOG.info("Adding extension for verifier %s." % verifier)

        # store original status to rollback it after failure
        original_status = verifier.status
        verifier.update_status(consts.VerifierStatus.EXTENDING)
        try:
            verifier.manager.install_extension(
                source, version=version, extra_settings=extra_settings
            )
        finally:
            verifier.update_status(original_status)

        LOG.info(
            "Extension for verifier %s has been successfully added!" % verifier
        )

    def list_extensions(self, verifier_id):
        """List all verifier extensions.

        :param verifier_id: Verifier name or UUID
        """
        verifier = self._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to list verifier extensions because verifier %s "
                "is in '%s' status, but should be in '%s.'"
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        return verifier.manager.list_extensions()

    def delete_extension(self, verifier_id, name):
        """Delete a verifier extension.

        :param verifier_id: Verifier name or UUID
        :param name: Verifier extension name
        """
        verifier = self._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to delete verifier extension because verifier %s "
                "is in '%s' status, but should be in '%s'."
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        LOG.info("Deleting extension for verifier %s." % verifier)
        verifier.manager.uninstall_extension(name)
        LOG.info(
            "Extension for verifier %s has been successfully deleted!"
            % verifier
        )


class _Verification(APIGroup):
    def start(self, verifier_id, deployment_id, tags=None, **run_args):
        """Start a verification.

        :param verifier_id: Verifier name or UUID
        :param deployment_id: Deployment name or UUID
        :param tags: List of tags to assign them to verification
        :param run_args: Dictionary with run arguments for verification
        """
        # TODO(ylobankov): Add an ability to skip tests by specifying only test
        #                  names (without test IDs). Also, it would be nice to
        #                  skip the whole test suites. For example, all tests
        #                  in the class or module.

        deployment = objects.Deployment.get(deployment_id)

        if deployment["status"] != consts.DeployStatus.DEPLOY_FINISHED:
            raise exceptions.DeploymentNotFinishedStatus(
                name=deployment["name"],
                uuid=deployment["uuid"],
                status=deployment["status"],
            )

        verifier = self.api.verifier._get(verifier_id)
        if verifier.status != consts.VerifierStatus.INSTALLED:
            raise exceptions.RallyException(
                "Failed to start verification because verifier %s is in '%s' "
                "status, but should be in '%s'."
                % (verifier, verifier.status, consts.VerifierStatus.INSTALLED)
            )

        verifier.set_env(deployment_id)
        if not verifier.manager.is_configured():
            self.api.verifier.configure(
                verifier=verifier, deployment_id=deployment_id
            )

        # TODO(andreykurilin): save validation results to db
        verifier.manager.validate(run_args)

        verification = objects.Verification.create(
            verifier_id=verifier_id,
            deployment_id=deployment_id,
            tags=tags,
            run_args=run_args,
        )
        LOG.info(
            "Starting verification (UUID=%s) for deployment '%s' "
            "(UUID=%s) by verifier %s."
            % (
                verification.uuid,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
                verifier,
            )
        )
        verification.update_status(consts.VerificationStatus.RUNNING)

        context = {
            "config": verifier.manager._meta_get("context"),
            "run_args": run_args,
            "verification": verification,
            "verifier": verifier,
        }
        try:
            with vcontext.ContextManager(context):
                results = verifier.manager.run(context)
        except Exception as e:
            verification.set_error(e)
            raise

        # TODO(ylobankov): Check that verification exists in the database
        #                  because users may delete verification before tests
        #                  finish.
        verification.finish(results.totals, results.tests)

        LOG.info(
            "Verification (UUID=%s) has been successfully finished for "
            "deployment '%s' (UUID=%s)!"
            % (
                verification.uuid,
                verifier.deployment["name"],
                verifier.deployment["uuid"],
            )
        )

        return {
            "verification": verification.to_dict(),
            "totals": results.totals,
            "tests": results.tests,
        }

    def rerun(
        self,
        verification_uuid,
        deployment_id=None,
        failed=False,
        tags=None,
        concurrency=0,
    ):
        """Rerun tests from a verification.

        :param verification_uuid: Verification UUID
        :param deployment_id: Deployment name or UUID
        :param failed: Rerun only failed tests
        :param tags: List of tags to assign them to verification
        :param concurrency: The number of processes to use to run verifier
            tests
        """
        # TODO(ylobankov): Improve this method in the future: put some
        #                  information about re-run in run_args.
        run_args = {}
        if concurrency:
            run_args["concurrency"] = concurrency

        verification = self._get(verification_uuid)
        tests = verification.tests

        if failed:
            tests = [t for t, r in tests.items() if r["status"] == "fail"]
            if not tests:
                raise exceptions.RallyException(
                    "There are no failed tests from verification (UUID=%s)."
                    % verification_uuid
                )
        else:
            # py2 and py3
            tests = list(tests.keys())

        deployment = (
            deployment_id if deployment_id else verification.deployment_uuid
        )
        deployment = self.api.deployment.get(deployment=deployment)
        LOG.info(
            "Re-running %stests from verification (UUID=%s) for "
            "deployment '%s' (UUID=%s)."
            % (
                "failed " if failed else "",
                verification.uuid,
                deployment["name"],
                deployment["uuid"],
            )
        )
        return self.start(
            verifier_id=verification.verifier_uuid,
            deployment_id=deployment["uuid"],
            load_list=tests,
            tags=tags,
            **run_args,
        )

    def _get(self, verification_uuid):
        """Get a verification.

        :param verification_uuid: Verification UUID
        """
        return objects.Verification.get(verification_uuid)

    def get(self, verification_uuid):
        return self._get(verification_uuid).to_dict()

    def list(
        self, verifier_id=None, deployment_id=None, tags=None, status=None
    ):
        """List all verifications.

        :param verifier_id: Verifier name or UUID
        :param deployment_id: Deployment name or UUID
        :param tags: Tags to filter verifications by
        :param status: Status to filter verifications by
        """
        return [
            item.to_dict()
            for item in objects.Verification.list(
                verifier_id,
                deployment_id=deployment_id,
                tags=tags,
                status=status,
            )
        ]

    def delete(self, verification_uuid):
        """Delete a verification.

        :param verification_uuid: Verification UUID
        """
        verification = self._get(verification_uuid)
        LOG.info("Deleting verification (UUID=%s)." % verification.uuid)
        verification.delete()
        LOG.info("Verification has been successfully deleted!")

    def report(self, uuids, output_type, output_dest=None):
        """Generate a report for a verification or a few verifications.

        :param uuids: List of verifications UUIDs
        :param output_type: Plugin name of verification reporter
        :param output_dest: Destination for verification report
        """
        verifications = [self._get(uuid) for uuid in uuids]

        reporter_cls = vreporter.VerificationReporter.get(output_type)
        reporter_cls.validate(output_dest)

        LOG.info(
            "Building '%s' report for the following verification(s): '%s'"
            % (output_type, "', '".join(uuids))
        )
        result = vreporter.VerificationReporter.make(
            reporter_cls, verifications, output_dest
        )
        LOG.info("The report has been successfully built.")
        return result

    def import_results(self, verifier_id, deployment_id, data, **run_args):
        """Import results of a test run into Rally database.

        :param verifier_id: Verifier name or UUID
        :param deployment_id: Deployment name or UUID
        :param data: Results data of a test run to import
        :param run_args: Dictionary with run arguments
        """
        # TODO(aplanas): Create an external deployment if this is missing, as
        # required in the blueprint [1].
        # [1] https://blueprints.launchpad.net/rally/+spec/verification-import

        verifier = self.api.verifier._get(verifier_id)
        verifier.set_env(deployment_id)
        LOG.info(
            "Importing test results into a new verification for "
            "deployment '%s' (UUID=%s), using verifier %s."
            % (
                verifier.deployment["name"],
                verifier.deployment["uuid"],
                verifier,
            )
        )

        verifier.manager.validate_args(run_args)

        verification = objects.Verification.create(
            verifier_id, deployment_id=deployment_id, run_args=run_args
        )
        verification.update_status(consts.VerificationStatus.RUNNING)

        try:
            results = verifier.manager.parse_results(data)
        except Exception as e:
            verification.set_failed(e)
            raise
        verification.finish(results.totals, results.tests)

        LOG.info("Test results have been successfully imported.")

        return {
            "verification": verification.to_dict(),
            "totals": results.totals,
            "tests": results.tests,
        }


class API:
    CONFIG_SEARCH_PATHS = [sys.prefix + "/etc/rally", "~/.rally", "/etc/rally"]
    CONFIG_FILE_NAME = "rally.conf"

    def __init__(
        self,
        config_file: str | None = None,
        config_args: list[str] | None = None,
        plugin_paths: list[str] | None = None,
        skip_db_check: bool = False,
    ):
        """Initialize Rally API instance

        :param config_file: Path to rally configuration file. If None, default
                            path will be selected
        :param config_args: Arguments for initialization current configuration
        :param plugin_paths: Additional custom plugin locations
        :param skip_db_check: Allows to skip db revision check
        """

        try:
            config_files = (
                [config_file] if config_file else self._default_config_file()
            )
            CONF(
                config_args or [],
                project="rally",
                version=rally_version.version_string(),
                default_config_files=config_files,
            )
            CONF.set_default("use_stderr", True)

            logging.setup("rally")
            if not CONF.get("log_config_append"):
                # The below two lines are to disable noise from request module.
                # The standard way should be we make such lots of settings on
                # the root rally. However current oslo codes doesn't support
                # such interface. So I choose to use a 'hacking' way to avoid
                # INFO logs from request module where user didn't give specific
                # log configuration. And we could remove this hacking after
                # oslo.log has such interface.
                LOG.debug(
                    "INFO logs from urllib3 and requests module are hide."
                )
                requests_log = logging.getLogger("requests").logger
                requests_log.setLevel(logging.WARNING)
                urllib3_log = logging.getLogger("urllib3").logger
                urllib3_log.setLevel(logging.WARNING)

                LOG.debug("urllib3 insecure warnings are hidden.")
                for warning in (
                    "InsecurePlatformWarning",
                    "SNIMissingWarning",
                    "InsecureRequestWarning",
                ):
                    warning_cls = getattr(urllib3.exceptions, warning, None)
                    if warning_cls is not None:
                        urllib3.disable_warnings(warning_cls)

            # NOTE(wtakase): This is for suppressing boto error logging.
            LOG.debug("ERROR log from boto module is hide.")
            boto_log = logging.getLogger("boto").logger
            boto_log.setLevel(logging.CRITICAL)

            # Set alembic log level to ERROR
            alembic_log = logging.getLogger("alembic").logger
            alembic_log.setLevel(logging.ERROR)

        except cfg.ConfigFilesNotFoundError as e:
            cfg_files = e.config_files
            raise exceptions.RallyException(
                "Failed to read configuration file(s): %s" % cfg_files
            )

        # Check that db is upgraded to the latest revision
        if not skip_db_check:
            self.check_db_revision()

        # Load plugins
        plugin_paths = plugin_paths or []
        if "plugin_paths" in CONF:
            plugin_paths.extend(CONF.get("plugin_paths") or [])
        for path in plugin_paths:
            discover.load_plugins(path)

        # NOTE(andreykurilin): There is no reason to auto-discover API's. We
        # have only 4 classes, so let's do it in good old way - hardcode them:)
        self._deployment = _Deployment(self)
        self._task = _Task(self)
        self._verifier = _Verifier(self)
        self._verification = _Verification(self)

    def _default_config_file(self):
        for path in self.CONFIG_SEARCH_PATHS:
            abspath = os.path.abspath(os.path.expanduser(path))
            fpath = os.path.join(abspath, self.CONFIG_FILE_NAME)
            if os.path.isfile(fpath):
                return [fpath]

    def check_db_revision(self):
        rev = rally_version.database_revision()

        # Check that db exists
        if rev["revision"] is None:
            raise exceptions.RallyException(
                "Database is missing. Create database by command "
                "`rally db create'"
            )

        # Check that db is updated
        if rev["revision"] != rev["current_head"]:
            raise exceptions.RallyException(
                (
                    "Database seems to be outdated. Run upgrade from "
                    "revision %(revision)s to %(current_head)s by command "
                    "`rally db upgrade'"
                )
                % rev
            )

    def _request(self, path, method, **kwargs):
        headers = {
            "RALLY-CLIENT-VERSION": rally_version.version_string(),
            "RALLY-API": "1.0",
        }
        response = requests.request(method, path, json=kwargs, headers=headers)
        if response.status_code != 200:
            raise exceptions.find_exception(response)

        return response.json(object_pairs_hook=collections.OrderedDict)[
            "result"
        ]

    @property
    def deployment(self) -> _Deployment:
        return self._deployment

    @property
    def task(self) -> _Task:
        return self._task

    @property
    def verifier(self) -> _Verifier:
        return self._verifier

    @property
    def verification(self) -> _Verification:
        return self._verification

    @property
    def version(self) -> int:
        return 1
