import builtins
import functools
from typing import NoReturn, Optional, Mapping, Any

from dbt.events.functions import fire_event, scrub_secrets, env_secrets
from dbt.events.types import GeneralWarningMsg, GeneralWarningException
from dbt.node_types import NodeType
from dbt import flags
from dbt.ui import line_wrap_message, warning_tag

import dbt.dataclass_schema


def validator_error_message(exc):
    """Given a dbt.dataclass_schema.ValidationError (which is basically a
    jsonschema.ValidationError), return the relevant parts as a string
    """
    if not isinstance(exc, dbt.dataclass_schema.ValidationError):
        return str(exc)
    path = "[%s]" % "][".join(map(repr, exc.relative_path))
    return "at path {}: {}".format(path, exc.message)


class Exception(builtins.Exception):
    CODE = -32000
    MESSAGE = "Server Error"

    def data(self):
        # if overriding, make sure the result is json-serializable.
        return {
            "type": self.__class__.__name__,
            "message": str(self),
        }


class MacroReturn(builtins.BaseException):
    """
    Hack of all hacks
    """

    def __init__(self, value):
        self.value = value


class InternalException(Exception):
    pass


class RuntimeException(RuntimeError, Exception):
    CODE = 10001
    MESSAGE = "Runtime error"

    def __init__(self, msg, node=None):
        self.stack = []
        self.node = node
        self.msg = scrub_secrets(msg, env_secrets())

    def add_node(self, node=None):
        if node is not None and node is not self.node:
            if self.node is not None:
                self.stack.append(self.node)
            self.node = node

    @property
    def type(self):
        return "Runtime"

    def node_to_string(self, node):
        if node is None:
            return "<Unknown>"
        if not hasattr(node, "name"):
            # we probably failed to parse a block, so we can't know the name
            return "{} ({})".format(node.resource_type, node.original_file_path)

        if hasattr(node, "contents"):
            # handle FileBlocks. They aren't really nodes but we want to render
            # out the path we know at least. This indicates an error during
            # block parsing.
            return "{}".format(node.path.original_file_path)
        return "{} {} ({})".format(node.resource_type, node.name, node.original_file_path)

    def process_stack(self):
        lines = []
        stack = self.stack + [self.node]
        first = True

        if len(stack) > 1:
            lines.append("")

            for item in stack:
                msg = "called by"

                if first:
                    msg = "in"
                    first = False

                lines.append("> {} {}".format(msg, self.node_to_string(item)))

        return lines

    def __str__(self, prefix="! "):
        node_string = ""

        if self.node is not None:
            node_string = " in {}".format(self.node_to_string(self.node))

        if hasattr(self.msg, "split"):
            split_msg = self.msg.split("\n")
        else:
            split_msg = str(self.msg).split("\n")

        lines = ["{}{}".format(self.type + " Error", node_string)] + split_msg

        lines += self.process_stack()

        return lines[0] + "\n" + "\n".join(["  " + line for line in lines[1:]])

    def data(self):
        result = Exception.data(self)
        if self.node is None:
            return result

        result.update(
            {
                "raw_sql": self.node.raw_sql,
                # the node isn't always compiled, but if it is, include that!
                "compiled_sql": getattr(self.node, "compiled_sql", None),
            }
        )
        return result


class RPCFailureResult(RuntimeException):
    CODE = 10002
    MESSAGE = "RPC execution error"


class RPCTimeoutException(RuntimeException):
    CODE = 10008
    MESSAGE = "RPC timeout error"

    def __init__(self, timeout):
        super().__init__(self.MESSAGE)
        self.timeout = timeout

    def data(self):
        result = super().data()
        result.update(
            {
                "timeout": self.timeout,
                "message": "RPC timed out after {}s".format(self.timeout),
            }
        )
        return result


class RPCKilledException(RuntimeException):
    CODE = 10009
    MESSAGE = "RPC process killed"

    def __init__(self, signum):
        self.signum = signum
        self.message = "RPC process killed by signal {}".format(self.signum)
        super().__init__(self.message)

    def data(self):
        return {
            "signum": self.signum,
            "message": self.message,
        }


class RPCCompiling(RuntimeException):
    CODE = 10010
    MESSAGE = 'RPC server is compiling the project, call the "status" method for' " compile status"

    def __init__(self, msg=None, node=None):
        if msg is None:
            msg = "compile in progress"
        super().__init__(msg, node)


class RPCLoadException(RuntimeException):
    CODE = 10011
    MESSAGE = (
        'RPC server failed to compile project, call the "status" method for' " compile status"
    )

    def __init__(self, cause):
        self.cause = cause
        self.message = "{}: {}".format(self.MESSAGE, self.cause["message"])
        super().__init__(self.message)

    def data(self):
        return {"cause": self.cause, "message": self.message}


class DatabaseException(RuntimeException):
    CODE = 10003
    MESSAGE = "Database Error"

    def process_stack(self):
        lines = []

        if hasattr(self.node, "build_path") and self.node.build_path:
            lines.append("compiled SQL at {}".format(self.node.build_path))

        return lines + RuntimeException.process_stack(self)

    @property
    def type(self):
        return "Database"


class CompilationException(RuntimeException):
    CODE = 10004
    MESSAGE = "Compilation Error"

    @property
    def type(self):
        return "Compilation"


class RecursionException(RuntimeException):
    pass


class ValidationException(RuntimeException):
    CODE = 10005
    MESSAGE = "Validation Error"


class ParsingException(RuntimeException):
    CODE = 10015
    MESSAGE = "Parsing Error"

    @property
    def type(self):
        return "Parsing"


class JSONValidationException(ValidationException):
    def __init__(self, typename, errors):
        self.typename = typename
        self.errors = errors
        self.errors_message = ", ".join(errors)
        msg = 'Invalid arguments passed to "{}" instance: {}'.format(
            self.typename, self.errors_message
        )
        super().__init__(msg)

    def __reduce__(self):
        # see https://stackoverflow.com/a/36342588 for why this is necessary
        return (JSONValidationException, (self.typename, self.errors))


class IncompatibleSchemaException(RuntimeException):
    def __init__(self, expected: str, found: Optional[str]):
        self.expected = expected
        self.found = found
        self.filename = "input file"

        super().__init__(self.get_message())

    def add_filename(self, filename: str):
        self.filename = filename
        self.msg = self.get_message()

    def get_message(self) -> str:
        found_str = "nothing"
        if self.found is not None:
            found_str = f'"{self.found}"'

        msg = (
            f'Expected a schema version of "{self.expected}" in '
            f"{self.filename}, but found {found_str}. Are you running with a "
            f"different version of dbt?"
        )
        return msg

    CODE = 10014
    MESSAGE = "Incompatible Schema"


class JinjaRenderingException(CompilationException):
    pass


class UndefinedMacroException(CompilationException):
    def __str__(self, prefix="! ") -> str:
        msg = super().__str__(prefix)
        return (
            f"{msg}. This can happen when calling a macro that does "
            "not exist. Check for typos and/or install package dependencies "
            'with "dbt deps".'
        )


class UnknownAsyncIDException(Exception):
    CODE = 10012
    MESSAGE = "RPC server got an unknown async ID"

    def __init__(self, task_id):
        self.task_id = task_id

    def __str__(self):
        return "{}: {}".format(self.MESSAGE, self.task_id)


class AliasException(ValidationException):
    pass


class DependencyException(Exception):
    # this can happen due to raise_dependency_error and its callers
    CODE = 10006
    MESSAGE = "Dependency Error"


class DbtConfigError(RuntimeException):
    CODE = 10007
    MESSAGE = "DBT Configuration Error"

    def __init__(self, message, project=None, result_type="invalid_project", path=None):
        self.project = project
        super().__init__(message)
        self.result_type = result_type
        self.path = path

    def __str__(self, prefix="! ") -> str:
        msg = super().__str__(prefix)
        if self.path is None:
            return msg
        else:
            return f"{msg}\n\nError encountered in {self.path}"


class FailFastException(RuntimeException):
    CODE = 10013
    MESSAGE = "FailFast Error"

    def __init__(self, message, result=None, node=None):
        super().__init__(msg=message, node=node)
        self.result = result

    @property
    def type(self):
        return "FailFast"


class DbtProjectError(DbtConfigError):
    pass


class DbtSelectorsError(DbtConfigError):
    pass


class DbtProfileError(DbtConfigError):
    pass


class SemverException(Exception):
    def __init__(self, msg=None):
        self.msg = msg
        if msg is not None:
            super().__init__(msg)
        else:
            super().__init__()


class VersionsNotCompatibleException(SemverException):
    pass


class NotImplementedException(Exception):
    pass


class FailedToConnectException(DatabaseException):
    pass


class CommandError(RuntimeException):
    def __init__(self, cwd, cmd, message="Error running command"):
        cmd_scrubbed = list(scrub_secrets(cmd_txt, env_secrets()) for cmd_txt in cmd)
        super().__init__(message)
        self.cwd = cwd
        self.cmd = cmd_scrubbed
        self.args = (cwd, cmd_scrubbed, message)

    def __str__(self):
        if len(self.cmd) == 0:
            return "{}: No arguments given".format(self.msg)
        return '{}: "{}"'.format(self.msg, self.cmd[0])


class ExecutableError(CommandError):
    def __init__(self, cwd, cmd, message):
        super().__init__(cwd, cmd, message)


class WorkingDirectoryError(CommandError):
    def __init__(self, cwd, cmd, message):
        super().__init__(cwd, cmd, message)

    def __str__(self):
        return '{}: "{}"'.format(self.msg, self.cwd)


class CommandResultError(CommandError):
    def __init__(self, cwd, cmd, returncode, stdout, stderr, message="Got a non-zero returncode"):
        super().__init__(cwd, cmd, message)
        self.returncode = returncode
        self.stdout = scrub_secrets(stdout.decode("utf-8"), env_secrets())
        self.stderr = scrub_secrets(stderr.decode("utf-8"), env_secrets())
        self.args = (cwd, self.cmd, returncode, self.stdout, self.stderr, message)

    def __str__(self):
        return "{} running: {}".format(self.msg, self.cmd)


class InvalidConnectionException(RuntimeException):
    def __init__(self, thread_id, known, node=None):
        self.thread_id = thread_id
        self.known = known
        super().__init__(
            msg="connection never acquired for thread {}, have {}".format(
                self.thread_id, self.known
            )
        )


class InvalidSelectorException(RuntimeException):
    def __init__(self, name: str):
        self.name = name
        super().__init__(name)


class DuplicateYamlKeyException(CompilationException):
    pass


def raise_compiler_error(msg, node=None) -> NoReturn:
    raise CompilationException(msg, node)


def raise_parsing_error(msg, node=None) -> NoReturn:
    raise ParsingException(msg, node)


def raise_database_error(msg, node=None) -> NoReturn:
    raise DatabaseException(msg, node)


def raise_dependency_error(msg) -> NoReturn:
    raise DependencyException(scrub_secrets(msg, env_secrets()))


def raise_git_cloning_error(error: CommandResultError) -> NoReturn:
    error.cmd = scrub_secrets(str(error.cmd), env_secrets())
    raise error


def raise_git_cloning_problem(repo) -> NoReturn:
    repo = scrub_secrets(repo, env_secrets())
    msg = """\
    Something went wrong while cloning {}
    Check the debug logs for more information
    """
    raise RuntimeException(msg.format(repo))


def disallow_secret_env_var(env_var_name) -> NoReturn:
    """Raise an error when a secret env var is referenced outside allowed
    rendering contexts"""
    msg = (
        "Secret env vars are allowed only in profiles.yml or packages.yml. "
        "Found '{env_var_name}' referenced elsewhere."
    )
    raise_parsing_error(msg.format(env_var_name=env_var_name))


def invalid_type_error(
    method_name, arg_name, got_value, expected_type, version="0.13.0"
) -> NoReturn:
    """Raise a CompilationException when an adapter method available to macros
    has changed.
    """
    got_type = type(got_value)
    msg = (
        "As of {version}, 'adapter.{method_name}' expects argument "
        "'{arg_name}' to be of type '{expected_type}', instead got "
        "{got_value} ({got_type})"
    )
    raise_compiler_error(
        msg.format(
            version=version,
            method_name=method_name,
            arg_name=arg_name,
            expected_type=expected_type,
            got_value=got_value,
            got_type=got_type,
        )
    )


def invalid_bool_error(got_value, macro_name) -> NoReturn:
    """Raise a CompilationException when a macro expects a boolean but gets some
    other value.
    """
    msg = (
        "Macro '{macro_name}' returns '{got_value}'.  It is not type 'bool' "
        "and cannot not be converted reliably to a bool."
    )
    raise_compiler_error(msg.format(macro_name=macro_name, got_value=got_value))


def ref_invalid_args(model, args) -> NoReturn:
    raise_compiler_error("ref() takes at most two arguments ({} given)".format(len(args)), model)


def metric_invalid_args(model, args) -> NoReturn:
    raise_compiler_error(
        "metric() takes at most two arguments ({} given)".format(len(args)), model
    )


def ref_bad_context(model, args) -> NoReturn:
    ref_args = ", ".join("'{}'".format(a) for a in args)
    ref_string = "{{{{ ref({}) }}}}".format(ref_args)

    base_error_msg = """dbt was unable to infer all dependencies for the model "{model_name}".
This typically happens when ref() is placed within a conditional block.

To fix this, add the following hint to the top of the model "{model_name}":

-- depends_on: {ref_string}"""
    # This explicitly references model['name'], instead of model['alias'], for
    # better error messages. Ex. If models foo_users and bar_users are aliased
    # to 'users', in their respective schemas, then you would want to see
    # 'bar_users' in your error messge instead of just 'users'.
    if isinstance(model, dict):  # TODO: remove this path
        model_name = model["name"]
        model_path = model["path"]
    else:
        model_name = model.name
        model_path = model.path
    error_msg = base_error_msg.format(
        model_name=model_name, model_path=model_path, ref_string=ref_string
    )
    raise_compiler_error(error_msg, model)


def doc_invalid_args(model, args) -> NoReturn:
    raise_compiler_error("doc() takes at most two arguments ({} given)".format(len(args)), model)


def doc_target_not_found(
    model, target_doc_name: str, target_doc_package: Optional[str]
) -> NoReturn:
    target_package_string = ""

    if target_doc_package is not None:
        target_package_string = "in package '{}' ".format(target_doc_package)

    msg = ("Documentation for '{}' depends on doc '{}' {} which was not found").format(
        model.unique_id, target_doc_name, target_package_string
    )
    raise_compiler_error(msg, model)


def _get_target_failure_msg(
    model,
    target_name: str,
    target_model_package: Optional[str],
    include_path: bool,
    reason: str,
    target_kind: str,
) -> str:
    target_package_string = ""
    if target_model_package is not None:
        target_package_string = "in package '{}' ".format(target_model_package)

    source_path_string = ""
    if include_path:
        source_path_string = " ({})".format(model.original_file_path)

    return "{} '{}'{} depends on a {} named '{}' {}which {}".format(
        model.resource_type.title(),
        model.unique_id,
        source_path_string,
        target_kind,
        target_name,
        target_package_string,
        reason,
    )


def get_target_not_found_or_disabled_msg(
    model,
    target_model_name: str,
    target_model_package: Optional[str],
    disabled: Optional[bool] = None,
) -> str:
    if disabled is None:
        reason = "was not found or is disabled"
    elif disabled is True:
        reason = "is disabled"
    else:
        reason = "was not found"
    return _get_target_failure_msg(
        model,
        target_model_name,
        target_model_package,
        include_path=True,
        reason=reason,
        target_kind="node",
    )


def ref_target_not_found(
    model,
    target_model_name: str,
    target_model_package: Optional[str],
    disabled: Optional[bool] = None,
) -> NoReturn:
    msg = get_target_not_found_or_disabled_msg(
        model, target_model_name, target_model_package, disabled
    )
    raise_compiler_error(msg, model)


def get_source_not_found_or_disabled_msg(
    model,
    target_name: str,
    target_table_name: str,
    disabled: Optional[bool] = None,
) -> str:
    full_name = f"{target_name}.{target_table_name}"
    if disabled is None:
        reason = "was not found or is disabled"
    elif disabled is True:
        reason = "is disabled"
    else:
        reason = "was not found"
    return _get_target_failure_msg(
        model, full_name, None, include_path=True, reason=reason, target_kind="source"
    )


def source_target_not_found(
    model, target_name: str, target_table_name: str, disabled: Optional[bool] = None
) -> NoReturn:
    msg = get_source_not_found_or_disabled_msg(model, target_name, target_table_name, disabled)
    raise_compiler_error(msg, model)


def get_metric_not_found_msg(
    model,
    target_name: str,
    target_package: Optional[str],
) -> str:
    reason = "was not found"
    return _get_target_failure_msg(
        model, target_name, target_package, include_path=True, reason=reason, target_kind="metric"
    )


def metric_target_not_found(metric, target_name: str, target_package: Optional[str]) -> NoReturn:
    msg = get_metric_not_found_msg(metric, target_name, target_package)

    raise_compiler_error(msg, metric)


def dependency_not_found(model, target_model_name):
    raise_compiler_error(
        "'{}' depends on '{}' which is not in the graph!".format(
            model.unique_id, target_model_name
        ),
        model,
    )


def macro_not_found(model, target_macro_id):
    raise_compiler_error(
        model,
        "'{}' references macro '{}' which is not defined!".format(
            model.unique_id, target_macro_id
        ),
    )


def macro_invalid_dispatch_arg(macro_name) -> NoReturn:
    msg = """\
    The "packages" argument of adapter.dispatch() has been deprecated.
    Use the "macro_namespace" argument instead.

    Raised during dispatch for: {}

    For more information, see:

    https://docs.getdbt.com/reference/dbt-jinja-functions/dispatch
    """
    raise_compiler_error(msg.format(macro_name))


def materialization_not_available(model, adapter_type):
    materialization = model.get_materialization()

    raise_compiler_error(
        "Materialization '{}' is not available for {}!".format(materialization, adapter_type),
        model,
    )


def missing_materialization(model, adapter_type):
    materialization = model.get_materialization()

    valid_types = "'default'"

    if adapter_type != "default":
        valid_types = "'default' and '{}'".format(adapter_type)

    raise_compiler_error(
        "No materialization '{}' was found for adapter {}! (searched types {})".format(
            materialization, adapter_type, valid_types
        ),
        model,
    )


def bad_package_spec(repo, spec, error_message):
    msg = "Error checking out spec='{}' for repo {}\n{}".format(spec, repo, error_message)
    raise InternalException(scrub_secrets(msg, env_secrets()))


def raise_cache_inconsistent(message):
    raise InternalException("Cache inconsistency detected: {}".format(message))


def missing_config(model, name):
    raise_compiler_error(
        "Model '{}' does not define a required config parameter '{}'.".format(
            model.unique_id, name
        ),
        model,
    )


def missing_relation(relation, model=None):
    raise_compiler_error("Relation {} not found!".format(relation), model)


def raise_dataclass_not_dict(obj):
    msg = (
        'The object ("{obj}") was used as a dictionary. This '
        "capability has been removed from objects of this type."
    )
    raise_compiler_error(msg)


def relation_wrong_type(relation, expected_type, model=None):
    raise_compiler_error(
        (
            "Trying to create {expected_type} {relation}, "
            "but it currently exists as a {current_type}. Either "
            "drop {relation} manually, or run dbt with "
            "`--full-refresh` and dbt will drop it for you."
        ).format(relation=relation, current_type=relation.type, expected_type=expected_type),
        model,
    )


def package_not_found(package_name):
    raise_dependency_error("Package {} was not found in the package index".format(package_name))


def package_version_not_found(package_name, version_range, available_versions):
    base_msg = (
        "Could not find a matching version for package {}\n"
        "  Requested range: {}\n"
        "  Available versions: {}"
    )
    raise_dependency_error(base_msg.format(package_name, version_range, available_versions))


def invalid_materialization_argument(name, argument):
    raise_compiler_error(
        "materialization '{}' received unknown argument '{}'.".format(name, argument)
    )


def system_error(operation_name):
    raise_compiler_error(
        "dbt encountered an error when attempting to {}. "
        "If this error persists, please create an issue at: \n\n"
        "https://github.com/dbt-labs/dbt-core".format(operation_name)
    )


class ConnectionException(Exception):
    """
    There was a problem with the connection that returned a bad response,
    timed out, or resulted in a file that is corrupt.
    """

    pass


def raise_dep_not_found(node, node_description, required_pkg):
    raise_compiler_error(
        'Error while parsing {}.\nThe required package "{}" was not found. '
        "Is the package installed?\nHint: You may need to run "
        "`dbt deps`.".format(node_description, required_pkg),
        node=node,
    )


def multiple_matching_relations(kwargs, matches):
    raise_compiler_error(
        "get_relation returned more than one relation with the given args. "
        "Please specify a database or schema to narrow down the result set."
        "\n{}\n\n{}".format(kwargs, matches)
    )


def get_relation_returned_multiple_results(kwargs, matches):
    multiple_matching_relations(kwargs, matches)


def approximate_relation_match(target, relation):
    raise_compiler_error(
        "When searching for a relation, dbt found an approximate match. "
        "Instead of guessing \nwhich relation to use, dbt will move on. "
        "Please delete {relation}, or rename it to be less ambiguous."
        "\nSearched for: {target}\nFound: {relation}".format(target=target, relation=relation)
    )


def raise_duplicate_macro_name(node_1, node_2, namespace) -> NoReturn:
    duped_name = node_1.name
    if node_1.package_name != node_2.package_name:
        extra = ' ("{}" and "{}" are both in the "{}" namespace)'.format(
            node_1.package_name, node_2.package_name, namespace
        )
    else:
        extra = ""

    raise_compiler_error(
        'dbt found two macros with the name "{}" in the namespace "{}"{}. '
        "Since these macros have the same name and exist in the same "
        "namespace, dbt will be unable to decide which to call. To fix this, "
        "change the name of one of these macros:\n- {} ({})\n- {} ({})".format(
            duped_name,
            namespace,
            extra,
            node_1.unique_id,
            node_1.original_file_path,
            node_2.unique_id,
            node_2.original_file_path,
        )
    )


def raise_duplicate_resource_name(node_1, node_2):
    duped_name = node_1.name
    node_type = NodeType(node_1.resource_type)
    pluralized = (
        node_type.pluralize()
        if node_1.resource_type == node_2.resource_type
        else "resources"  # still raise if ref() collision, e.g. model + seed
    )

    action = "looking for"
    # duplicate 'ref' targets
    if node_type in NodeType.refable():
        formatted_name = f'ref("{duped_name}")'
    # duplicate sources
    elif node_type == NodeType.Source:
        duped_name = node_1.get_full_source_name()
        formatted_name = node_1.get_source_representation()
    # duplicate docs blocks
    elif node_type == NodeType.Documentation:
        formatted_name = f'doc("{duped_name}")'
    # duplicate generic tests
    elif node_type == NodeType.Test and hasattr(node_1, "test_metadata"):
        column_name = f'column "{node_1.column_name}" in ' if node_1.column_name else ""
        model_name = node_1.file_key_name
        duped_name = f'{node_1.name}" defined on {column_name}"{model_name}'
        action = "running"
        formatted_name = "tests"
    # all other resource types
    else:
        formatted_name = duped_name

    # should this be raise_parsing_error instead?
    raise_compiler_error(
        f"""
dbt found two {pluralized} with the name "{duped_name}".

Since these resources have the same name, dbt will be unable to find the correct resource
when {action} {formatted_name}.

To fix this, change the name of one of these resources:
- {node_1.unique_id} ({node_1.original_file_path})
- {node_2.unique_id} ({node_2.original_file_path})
    """.strip()
    )


def raise_ambiguous_alias(node_1, node_2, duped_name=None):
    if duped_name is None:
        duped_name = f"{node_1.database}.{node_1.schema}.{node_1.alias}"

    raise_compiler_error(
        'dbt found two resources with the database representation "{}".\ndbt '
        "cannot create two resources with identical database representations. "
        "To fix this,\nchange the configuration of one of these resources:"
        "\n- {} ({})\n- {} ({})".format(
            duped_name,
            node_1.unique_id,
            node_1.original_file_path,
            node_2.unique_id,
            node_2.original_file_path,
        )
    )


def raise_ambiguous_catalog_match(unique_id, match_1, match_2):
    def get_match_string(match):
        return "{}.{}".format(
            match.get("metadata", {}).get("schema"),
            match.get("metadata", {}).get("name"),
        )

    raise_compiler_error(
        "dbt found two relations in your warehouse with similar database "
        "identifiers. dbt\nis unable to determine which of these relations "
        'was created by the model "{unique_id}".\nIn order for dbt to '
        "correctly generate the catalog, one of the following relations must "
        "be deleted or renamed:\n\n - {match_1_s}\n - {match_2_s}".format(
            unique_id=unique_id,
            match_1_s=get_match_string(match_1),
            match_2_s=get_match_string(match_2),
        )
    )


def raise_patch_targets_not_found(patches):
    patch_list = "\n\t".join(
        "model {} (referenced in path {})".format(p.name, p.original_file_path)
        for p in patches.values()
    )
    raise_compiler_error(
        "dbt could not find models for the following patches:\n\t{}".format(patch_list)
    )


def _fix_dupe_msg(path_1: str, path_2: str, name: str, type_name: str) -> str:
    if path_1 == path_2:
        return (
            f"remove one of the {type_name} entries for {name} in this file:\n" f" - {path_1!s}\n"
        )
    else:
        return (
            f"remove the {type_name} entry for {name} in one of these files:\n"
            f" - {path_1!s}\n{path_2!s}"
        )


def raise_duplicate_patch_name(patch_1, existing_patch_path):
    name = patch_1.name
    fix = _fix_dupe_msg(
        patch_1.original_file_path,
        existing_patch_path,
        name,
        "resource",
    )
    raise_compiler_error(
        f"dbt found two schema.yml entries for the same resource named "
        f"{name}. Resources and their associated columns may only be "
        f"described a single time. To fix this, {fix}"
    )


def raise_duplicate_macro_patch_name(patch_1, existing_patch_path):
    package_name = patch_1.package_name
    name = patch_1.name
    fix = _fix_dupe_msg(patch_1.original_file_path, existing_patch_path, name, "macros")
    raise_compiler_error(
        f"dbt found two schema.yml entries for the same macro in package "
        f"{package_name} named {name}. Macros may only be described a single "
        f"time. To fix this, {fix}"
    )


def raise_duplicate_source_patch_name(patch_1, patch_2):
    name = f"{patch_1.overrides}.{patch_1.name}"
    fix = _fix_dupe_msg(
        patch_1.path,
        patch_2.path,
        name,
        "sources",
    )
    raise_compiler_error(
        f"dbt found two schema.yml entries for the same source named "
        f"{patch_1.name} in package {patch_1.overrides}. Sources may only be "
        f"overridden a single time. To fix this, {fix}"
    )


def raise_invalid_property_yml_version(path, issue):
    raise_compiler_error(
        "The yml property file at {} is invalid because {}. Please consult the "
        "documentation for more information on yml property file syntax:\n\n"
        "https://docs.getdbt.com/reference/configs-and-properties".format(path, issue)
    )


def raise_unrecognized_credentials_type(typename, supported_types):
    raise_compiler_error(
        'Unrecognized credentials type "{}" - supported types are ({})'.format(
            typename, ", ".join('"{}"'.format(t) for t in supported_types)
        )
    )


def warn_invalid_patch(patch, resource_type):
    msg = line_wrap_message(
        f"""\
        '{patch.name}' is a {resource_type} node, but it is
        specified in the {patch.yaml_key} section of
        {patch.original_file_path}.
        To fix this error, place the `{patch.name}`
        specification under the {resource_type.pluralize()} key instead.
        """
    )
    warn_or_error(msg, log_fmt=warning_tag("{}"))


def raise_not_implemented(msg):
    raise NotImplementedException("ERROR: {}".format(msg))


def raise_duplicate_alias(
    kwargs: Mapping[str, Any], aliases: Mapping[str, str], canonical_key: str
) -> NoReturn:
    # dupe found: go through the dict so we can have a nice-ish error
    key_names = ", ".join("{}".format(k) for k in kwargs if aliases.get(k) == canonical_key)

    raise AliasException(f'Got duplicate keys: ({key_names}) all map to "{canonical_key}"')


def warn_or_error(msg, node=None, log_fmt=None):
    if flags.WARN_ERROR:
        raise_compiler_error(scrub_secrets(msg, env_secrets()), node)
    else:
        fire_event(GeneralWarningMsg(msg=msg, log_fmt=log_fmt))


def warn_or_raise(exc, log_fmt=None):
    if flags.WARN_ERROR:
        raise exc
    else:
        fire_event(GeneralWarningException(exc=exc, log_fmt=log_fmt))


def warn(msg, node=None):
    # there's no reason to expose log_fmt to macros - it's only useful for
    # handling colors
    warn_or_error(msg, node=node)
    return ""


# Update this when a new function should be added to the
# dbt context's `exceptions` key!
CONTEXT_EXPORTS = {
    fn.__name__: fn
    for fn in [
        warn,
        missing_config,
        missing_materialization,
        missing_relation,
        raise_ambiguous_alias,
        raise_ambiguous_catalog_match,
        raise_cache_inconsistent,
        raise_dataclass_not_dict,
        raise_compiler_error,
        raise_database_error,
        raise_dep_not_found,
        raise_dependency_error,
        raise_duplicate_patch_name,
        raise_duplicate_resource_name,
        raise_invalid_property_yml_version,
        raise_not_implemented,
        relation_wrong_type,
    ]
}


def wrapper(model):
    def wrap(func):
        @functools.wraps(func)
        def inner(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except RuntimeException as exc:
                exc.add_node(model)
                raise exc

        return inner

    return wrap


def wrapped_exports(model):
    wrap = wrapper(model)
    return {name: wrap(export) for name, export in CONTEXT_EXPORTS.items()}
