import json
from typing import List

from dvt.constants import MAXIMUM_SEED_SIZE_NAME, PIN_PACKAGE_URL
from dvt.events.base_types import (
    DebugLevel,
    DynamicLevel,
    ErrorLevel,
    InfoLevel,
    WarnLevel,
)
from dvt.flags import get_flags
from dbt_common.events.base_types import EventLevel
from dbt_common.events.format import (
    format_fancy_output_line,
    pluralize,
    timestamp_to_datetime_string,
)
from dbt_common.ui import deprecation_tag as deprecation_tag_less_strict
from dbt_common.ui import error_tag, green, line_wrap_message, red, warning_tag, yellow


# This makes it so that mypy will complain if a deprecation tag is used without an event name
def _deprecation_tag(description: str, event_name: str) -> str:
    return deprecation_tag_less_strict(description, event_name)


# Event codes have prefixes which follow this table
#
# | Code |     Description     |
# |:----:|:-------------------:|
# | A    | Pre-project loading |
# | D    | Deprecations        |
# | E    | DB adapter          |
# | I    | Project parsing     |
# | M    | Deps generation     |
# | P    | Artifacts           |
# | Q    | Node execution      |
# | W    | Node testing        |
# | Z    | Misc                |
# | T    | Test only           |
#
# The basic idea is that event codes roughly translate to the natural order of running a dbt task

# =======================================================
# A - Pre-project loading
# =======================================================


class MainReportVersion(InfoLevel):
    def code(self) -> str:
        return "A001"

    def message(self) -> str:
        return f"Running with dvt{self.version}"


class MainReportArgs(DebugLevel):
    def code(self) -> str:
        return "A002"

    def message(self) -> str:
        return f"running dvt with arguments {str(self.args)}"


class MainTrackingUserState(DebugLevel):
    def code(self) -> str:
        return "A003"

    def message(self) -> str:
        return f"Tracking: {self.user_state}"


# Removed A004: MergedFromState


class MissingProfileTarget(InfoLevel):
    def code(self) -> str:
        return "A005"

    def message(self) -> str:
        return f"target not specified in profile '{self.profile_name}', using '{self.target_name}'"


# Skipped A006, A007


class InvalidOptionYAML(ErrorLevel):
    def code(self) -> str:
        return "A008"

    def message(self) -> str:
        return f"The YAML provided in the --{self.option_name} argument is not valid."


class LogDbtProjectError(ErrorLevel):
    def code(self) -> str:
        return "A009"

    def message(self) -> str:
        msg = "Encountered an error while reading the project:"
        if self.exc:
            msg += f"  ERROR: {str(self.exc)}"
        return msg


# Skipped A010


class LogDbtProfileError(ErrorLevel):
    def code(self) -> str:
        return "A011"

    def message(self) -> str:
        msg = f"Encountered an error while reading profiles:\n  ERROR: {str(self.exc)}"
        if self.profiles:
            msg += "Defined profiles:\n"
            for profile in self.profiles:
                msg += f" - {profile}"
        else:
            msg += "There are no profiles defined in your profiles.yml file"

        msg += """
For more information on configuring profiles, please consult the DVT docs:

https://github.com/heshamh96/dvt-ce#configuration-files
"""
        return msg


class StarterProjectPath(DebugLevel):
    def code(self) -> str:
        return "A017"

    def message(self) -> str:
        return f"Starter project path: {self.dir}"


class ConfigFolderDirectory(InfoLevel):
    def code(self) -> str:
        return "A018"

    def message(self) -> str:
        return f"Creating dvt configuration folder at {self.dir}"


class NoSampleProfileFound(InfoLevel):
    def code(self) -> str:
        return "A019"

    def message(self) -> str:
        return f"No sample profile found for {self.adapter}."


class ProfileWrittenWithSample(InfoLevel):
    def code(self) -> str:
        return "A020"

    def message(self) -> str:
        return (
            f"Profile {self.name} written to {self.path} "
            "using target's sample configuration. Once updated, you'll be able to "
            "start developing with dvt."
        )


class ProfileWrittenWithTargetTemplateYAML(InfoLevel):
    def code(self) -> str:
        return "A021"

    def message(self) -> str:
        return (
            f"Profile {self.name} written to {self.path} using target's "
            "profile_template.yml and your supplied values. Run 'dvt debug' to "
            "validate the connection."
        )


class ProfileWrittenWithProjectTemplateYAML(InfoLevel):
    def code(self) -> str:
        return "A022"

    def message(self) -> str:
        return (
            f"Profile {self.name} written to {self.path} using project's "
            "profile_template.yml and your supplied values. Run 'dvt debug' to "
            "validate the connection."
        )


class SettingUpProfile(InfoLevel):
    def code(self) -> str:
        return "A023"

    def message(self) -> str:
        return "Setting up your profile."


class InvalidProfileTemplateYAML(InfoLevel):
    def code(self) -> str:
        return "A024"

    def message(self) -> str:
        return "Invalid profile_template.yml in project."


class ProjectNameAlreadyExists(InfoLevel):
    def code(self) -> str:
        return "A025"

    def message(self) -> str:
        return f"A project called {self.name} already exists here."


class ProjectCreated(InfoLevel):
    def code(self) -> str:
        return "A026"

    def message(self) -> str:
        return f"""
Your new DVT project "{self.project_name}" was created!

Next steps:
  1. Edit your profiles.yml to configure database connections
     (see https://docs.getdbt.com/docs/available-adapters for supported adapters)
  2. Edit your computes.yml to configure Spark compute engines
  3. Run 'dvt sync' to install adapters, PySpark, and JDBC drivers
  4. Run 'dvt debug' to verify all connections
  5. Run 'dvt run' to execute your models

Documentation: https://github.com/heshamh96/dvt-ce
Need help? https://discord.gg/UjQcxJXAQp
"""


# =======================================================
# D - Deprecations
# =======================================================


def require_event_names_in_deprecations():
    # The require_event_names_in_deprecations flag isn't guaranteed to be set by the
    # time some deprecations are fired. We could have done the following ever single deprecation
    # that needs it, but this makes it simpler to flip the flag later.
    return getattr(get_flags(), "require_event_names_in_deprecations", False)


class DeprecatedModel(WarnLevel):
    def code(self) -> str:
        return "I065"

    def message(self) -> str:
        version = ".v" + self.model_version if self.model_version else ""
        msg = (
            f"Model {self.model_name}{version} has passed its deprecation date of {self.deprecation_date}. "
            "This model should be disabled or removed."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(_deprecation_tag(msg, self.__class__.__name__))
        else:
            return warning_tag(msg)


class PackageRedirectDeprecation(WarnLevel):
    def code(self) -> str:
        return "D001"

    def message(self) -> str:
        description = (
            f"The `{self.old_name}` package is deprecated in favor of `{self.new_name}`. Please "
            f"update your `packages.yml` configuration to use `{self.new_name}` instead."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class PackageInstallPathDeprecation(WarnLevel):
    def code(self) -> str:
        return "D002"

    def message(self) -> str:
        description = """\
        The default package install path has changed from `dbt_modules` to `dbt_packages`.
        Please update `clean-targets` in `dbt_project.yml` and check `.gitignore` as well.
        Or, set `packages-install-path: dbt_modules` if you'd like to keep the current value.
        """

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class ConfigSourcePathDeprecation(WarnLevel):
    def code(self) -> str:
        return "D003"

    def message(self) -> str:
        description = (
            f"The `{self.deprecated_path}` config has been renamed to `{self.exp_path}`. "
            "Please update your `dbt_project.yml` configuration to reflect this change."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class ConfigDataPathDeprecation(WarnLevel):
    def code(self) -> str:
        return "D004"

    def message(self) -> str:
        description = (
            f"The `{self.deprecated_path}` config has been renamed to `{self.exp_path}`. "
            "Please update your `dbt_project.yml` configuration to reflect this change."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class MetricAttributesRenamed(WarnLevel):
    def code(self) -> str:
        return "D006"

    def message(self) -> str:
        description = (
            "dvt-ce v1.3 renamed attributes for metrics:"
            "\n  'sql'              -> 'expression'"
            "\n  'type'             -> 'calculation_method'"
            "\n  'type: expression' -> 'calculation_method: derived'"
            f"\nPlease remove them from the metric definition of metric '{self.metric_name}'"
            "\nRelevant issue here: https://github.com/dvt-labs/dvt-ce/issues/5849"
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return deprecation_tag_less_strict(description)


class ExposureNameDeprecation(WarnLevel):
    def code(self) -> str:
        return "D007"

    def message(self) -> str:
        description = (
            "Starting in v1.3, the 'name' of an exposure should contain only letters, "
            "numbers, and underscores. Exposures support a new property, 'label', which may "
            f"contain spaces, capital letters, and special characters. {self.exposure} does not "
            "follow this pattern. Please update the 'name', and use the 'label' property for a "
            "human-friendly title. This will raise an error in a future version of dvt-ce."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class InternalDeprecation(WarnLevel):
    def code(self) -> str:
        return "D008"

    def message(self) -> str:
        extra_reason = ""
        if self.reason:
            extra_reason = f"\n{self.reason}"
        msg = (
            f"`{self.name}` is deprecated and will be removed in dvt-ce version {self.version}\n\n"
            f"Adapter maintainers can resolve this deprecation by {self.suggested_action}. {extra_reason}"
        )

        if require_event_names_in_deprecations():
            return _deprecation_tag(msg, self.__class__.__name__)
        else:
            return warning_tag(msg)


class EnvironmentVariableRenamed(WarnLevel):
    def code(self) -> str:
        return "D009"

    def message(self) -> str:
        description = (
            f"The environment variable `{self.old_name}` has been renamed as `{self.new_name}`.\n"
            f"If `{self.old_name}` is currently set, its value will be used instead of `{self.new_name}`.\n"
            f"Set `{self.new_name}` and unset `{self.old_name}` to avoid this deprecation warning and "
            "ensure it works properly in a future release."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class ConfigLogPathDeprecation(WarnLevel):
    def code(self) -> str:
        return "D010"

    def message(self) -> str:
        output = "logs"
        cli_flag = "--log-path"
        env_var = "DBT_LOG_PATH"
        description = (
            f"The `{self.deprecated_path}` config in `dbt_project.yml` has been deprecated, "
            f"and will no longer be supported in a future version of dvt-ce. "
            f"If you wish to write dvt {output} to a custom directory, please use "
            f"the {cli_flag} CLI flag or {env_var} env var instead."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class ConfigTargetPathDeprecation(WarnLevel):
    def code(self) -> str:
        return "D011"

    def message(self) -> str:
        output = "artifacts"
        cli_flag = "--target-path"
        env_var = "DBT_TARGET_PATH"
        description = (
            f"The `{self.deprecated_path}` config in `dbt_project.yml` has been deprecated, "
            f"and will no longer be supported in a future version of dvt-ce. "
            f"If you wish to write dvt {output} to a custom directory, please use "
            f"the {cli_flag} CLI flag or {env_var} env var instead."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


# Note: this deprecation has been removed, but we are leaving
# the event class here, because users may have specified it in
# warn_error_options.
class TestsConfigDeprecation(WarnLevel):
    def code(self) -> str:
        return "D012"

    def message(self) -> str:
        description = (
            f"The `{self.deprecated_path}` config has been renamed to `{self.exp_path}`. "
            "Please see https://docs.getdbt.com/docs/build/data-tests#new-data_tests-syntax for more information."
        )

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(deprecation_tag_less_strict(description))


class ProjectFlagsMovedDeprecation(WarnLevel):
    def code(self) -> str:
        return "D013"

    def message(self) -> str:
        description = (
            "User config should be moved from the 'config' key in profiles.yml to the 'flags' "
            "key in dbt_project.yml."
        )
        # Can't use line_wrap_message here because flags.printer_width isn't available yet
        if require_event_names_in_deprecations():
            return _deprecation_tag(description, self.__class__.__name__)
        else:
            return deprecation_tag_less_strict(description)


class SpacesInResourceNameDeprecation(DynamicLevel):
    def code(self) -> str:
        return "D014"

    def message(self) -> str:
        description = f"Found spaces in the name of `{self.unique_id}`"

        if self.level == EventLevel.ERROR.value:
            description = error_tag(description)
        elif self.level == EventLevel.WARN.value:
            description = warning_tag(description)

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(description)


class ResourceNamesWithSpacesDeprecation(WarnLevel):
    def code(self) -> str:
        return "D015"

    def message(self) -> str:
        description = f"Spaces found in {self.count_invalid_names} resource name(s). This is deprecated, and may lead to errors when using dvt."

        if self.show_debug_hint:
            description += " Run again with `--debug` to see them all."

        description += " For more information: https://docs.getdbt.com/reference/global-configs/legacy-behaviors"

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


class PackageMaterializationOverrideDeprecation(WarnLevel):
    def code(self) -> str:
        return "D016"

    def message(self) -> str:
        description = f"Installed package '{self.package_name}' is overriding the built-in materialization '{self.materialization_name}'. Overrides of built-in materializations from installed packages will be deprecated in future versions of dvt. For more information: https://docs.getdbt.com/reference/global-configs/legacy-behaviors"

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


class SourceFreshnessProjectHooksNotRun(WarnLevel):
    def code(self) -> str:
        return "D017"

    def message(self) -> str:
        description = "In a future version of dvt, the `source freshness` command will start running `on-run-start` and `on-run-end` hooks by default. For more information: https://docs.getdbt.com/reference/global-configs/legacy-behaviors"

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


class MFTimespineWithoutYamlConfigurationDeprecation(WarnLevel):
    def code(self) -> str:
        return "D018"

    def message(self) -> str:
        description = "Time spines without YAML configuration are in the process of deprecation. Please add YAML configuration for your 'metricflow_time_spine' model. See documentation on MetricFlow time spines: https://docs.getdbt.com/docs/build/metricflow-time-spine and behavior change documentation: https://docs.getdbt.com/reference/global-configs/behavior-changes."

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


class MFCumulativeTypeParamsDeprecation(WarnLevel):
    def code(self) -> str:
        return "D019"

    def message(self) -> str:
        description = "Cumulative fields `type_params.window` and `type_params.grain_to_date` have been moved and will soon be deprecated. Please nest those values under `type_params.cumulative_type_params.window` and `type_params.cumulative_type_params.grain_to_date`. See documentation on behavior changes: https://docs.getdbt.com/reference/global-configs/behavior-changes."

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


class MicrobatchMacroOutsideOfBatchesDeprecation(WarnLevel):
    def code(self) -> str:
        return "D020"

    def message(self) -> str:
        description = "The use of a custom microbatch macro outside of batched execution is deprecated. To use it with batched execution, set `flags.require_batched_execution_for_custom_microbatch_strategy` to `True` in `dbt_project.yml`. In the future this will be the default behavior."

        if require_event_names_in_deprecations():
            return line_wrap_message(
                _deprecation_tag(description, self.__class__.__name__)
            )
        else:
            return line_wrap_message(warning_tag(description))


# Skipping D021. It belonged to the now deleted PackageRedirectDeprecationSummary event.


class GenericJSONSchemaValidationDeprecation(WarnLevel):
    def code(self) -> str:
        return "D022"

    def message(self) -> str:
        possible_causes = "This generally means that either we failed to catch this as a more specific deprecation type OR our JSONSchema had a regression (and this deprecation was erroneous)."

        if self.key_path == "":
            description = f"{self.violation} at top level in file `{self.file}` is possibly a deprecation. {possible_causes}"
        else:
            description = f"{self.violation} in file `{self.file}` at path `{self.key_path}` is possibly a deprecation. {possible_causes}"

        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class UnexpectedJinjaBlockDeprecation(WarnLevel):
    def code(self) -> str:
        return "D023"

    def message(self) -> str:
        description = f"{self.msg} in file `{self.file}`"
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class DuplicateYAMLKeysDeprecation(WarnLevel):
    def code(self) -> str:
        return "D024"

    def message(self) -> str:
        description = f"{self.duplicate_description} in file `{self.file}`"
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class CustomTopLevelKeyDeprecation(WarnLevel):
    def code(self) -> str:
        return "D025"

    def message(self) -> str:
        description = f"{self.msg} in file `{self.file}`"
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class CustomKeyInConfigDeprecation(WarnLevel):
    def code(self) -> str:
        return "D026"

    def message(self) -> str:
        path_specification = ""
        if self.key_path != "":
            path_specification = f" at path `{self.key_path}`"

        description = f"Custom key `{self.key}` found in `config`{path_specification} in file `{self.file}`. Custom config keys should move into the `config.meta`."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class CustomKeyInObjectDeprecation(WarnLevel):
    def code(self) -> str:
        return "D027"

    def message(self) -> str:
        description = f"Custom key `{self.key}` found at `{self.key_path}` in file `{self.file}`. This may mean the key is a typo, or is simply not a key supported by the object."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class DeprecationsSummary(WarnLevel):
    def code(self) -> str:
        return "D028"

    def message(self) -> str:
        description = "Summary of encountered deprecations:"
        for summary in self.summaries:
            description += f"\n\n- {summary.event_name}: {pluralize(summary.occurrences, 'occurrence')}"

        if self.show_all_hint:
            description += "\n\nTo see all deprecation instances instead of just the first occurrence of each, run command again with the `--show-all-deprecations` flag. You may also need to run with `--no-partial-parse` as some deprecations are only encountered during parsing."

        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class CustomOutputPathInSourceFreshnessDeprecation(WarnLevel):
    def code(self) -> str:
        return "D029"

    def message(self) -> str:
        description = f"Custom output path usage `--output {self.path}` usage detected in `dvt source freshness` command."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class PropertyMovedToConfigDeprecation(WarnLevel):
    def code(self) -> str:
        return "D030"

    def message(self) -> str:
        description = f"Found `{self.key}` as a top-level property of `{self.key_path}` in file `{self.file}`. The `{self.key}` top-level property should be moved into the `config` of `{self.key_path}`."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class WEOIncludeExcludeDeprecation(WarnLevel):
    def code(self) -> str:
        return "D031"

    def message(self) -> str:
        found_keys: List[str] = []
        if self.found_include:
            found_keys.append("`include`")
        if self.found_exclude:
            found_keys.append("`exclude`")

        description = (
            f"Found {' and '.join(found_keys)} in `warn_error_options` specification."
        )
        if self.found_include:
            description += " Please use `error` instead of `include`."
        if self.found_exclude:
            description += " Please use `warn` instead of `exclude`."

        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class ModelParamUsageDeprecation(WarnLevel):
    def code(self) -> str:
        return "D032"

    def message(self) -> str:
        description = "Usage of `--models`, `--model`, and `-m` is deprecated in favor of `--select` or `-s`."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class ModulesItertoolsUsageDeprecation(WarnLevel):
    def code(self) -> str:
        return "D034"

    def message(self) -> str:
        description = "Usage of itertools modules is deprecated. Please use the built-in functions instead."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class SourceOverrideDeprecation(WarnLevel):
    def code(self) -> str:
        return "D035"

    def message(self) -> str:
        description = f"The source property `overrides` is deprecated but was found on source `{self.source_name}` in file `{self.file}`. Instead, `enabled` should be used to disable the unwanted source."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class EnvironmentVariableNamespaceDeprecation(WarnLevel):
    def code(self) -> str:
        return "D036"

    def message(self) -> str:
        description = f"Found custom environment variable `{self.env_var}` in the environment. The prefix `{self.reserved_prefix}` is reserved for dvt engine environment variables. Custom environment variables with the prefix `{self.reserved_prefix}` may cause collisions and runtime errors."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class MissingPlusPrefixDeprecation(WarnLevel):
    def code(self) -> str:
        return "D037"

    def message(self) -> str:
        description = f"Missing '+' prefix on `{self.key}` found at `{self.key_path}` in file `{self.file}`. Hierarchical config values without a '+' prefix are deprecated in dbt_project.yml."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class ArgumentsPropertyInGenericTestDeprecation(WarnLevel):
    def code(self) -> str:
        return "D038"

    def message(self) -> str:
        description = f"Found `arguments` property in test definition of {self.test_name} without usage of `require_generic_test_arguments_property` behavior change flag. The `arguments` property is deprecated for custom usage and will be used to nest keyword arguments in future versions of dvt."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class MissingArgumentsPropertyInGenericTestDeprecation(WarnLevel):
    def code(self) -> str:
        return "D039"

    def message(self) -> str:
        description = f"Found top-level arguments to test {self.test_name}. Arguments to generic tests should be nested under the `arguments` property."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class DuplicateNameDistinctNodeTypesDeprecation(WarnLevel):
    def code(self) -> str:
        return "D040"

    def message(self) -> str:
        description = f"Found resources with the same name '{self.resource_name}' in package '{self.package_name}': '{self.unique_id1}' and '{self.unique_id2}'. Please update one of the resources to have a unique name."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


class TimeDimensionsRequireGranularityDeprecation(WarnLevel):
    def code(self) -> str:
        return "D041"

    def message(self) -> str:
        return line_wrap_message(_deprecation_tag(self.msg, self.__class__.__name__))


class GenericSemanticLayerDeprecation(WarnLevel):
    def code(self) -> str:
        return "D042"

    def message(self) -> str:
        return line_wrap_message(_deprecation_tag(self.msg, self.__class__.__name__))


class GenerateSchemaNameNullValueDeprecation(WarnLevel):
    def code(self) -> str:
        return "D044"

    def message(self) -> str:
        description = f"Node '{self.resource_unique_id}' has a schema set to None as a result of a generate_schema_name call. Please set a valid schema name."
        return line_wrap_message(_deprecation_tag(description, self.__class__.__name__))


# =======================================================
# I - Project parsing
# =======================================================


class InputFileDiffError(DebugLevel):
    def code(self) -> str:
        return "I001"

    def message(self) -> str:
        return f"Error processing file diff: {self.category}, {self.file_id}"


# Skipping I003, I004, I005, I006, I007


class InvalidValueForField(WarnLevel):
    def code(self) -> str:
        return "I008"

    def message(self) -> str:
        return f"Invalid value ({self.field_value}) for field {self.field_name}"


class ValidationWarning(WarnLevel):
    def code(self) -> str:
        return "I009"

    def message(self) -> str:
        return warning_tag(
            f"Field {self.field_name} is not valid for {self.resource_type} ({self.node_name})"
        )


class ParsePerfInfoPath(InfoLevel):
    def code(self) -> str:
        return "I010"

    def message(self) -> str:
        return f"Performance info: {self.path}"


# Removed I011: GenericTestFileParse


# Removed I012: MacroFileParse


# Skipping I013


class PartialParsingErrorProcessingFile(DebugLevel):
    def code(self) -> str:
        return "I014"

    def message(self) -> str:
        return f"Partial parsing exception processing file {self.file}"


# Skipped I015


class PartialParsingError(DebugLevel):
    def code(self) -> str:
        return "I016"

    def message(self) -> str:
        return f"PP exception info: {self.exc_info}"


class PartialParsingSkipParsing(DebugLevel):
    def code(self) -> str:
        return "I017"

    def message(self) -> str:
        return "Partial parsing enabled, no changes found, skipping parsing"


# Skipped I018, I019, I020, I021, I022, I023


class UnableToPartialParse(InfoLevel):
    def code(self) -> str:
        return "I024"

    def message(self) -> str:
        return f"Unable to do partial parsing because {self.reason}"


class StateCheckVarsHash(DebugLevel):
    def code(self) -> str:
        return "I025"

    def message(self) -> str:
        return f"checksum: {self.checksum}, vars: {self.vars}, profile: {self.profile}, target: {self.target}, version: {self.version}"


# Skipped I025, I026, I026, I027


class PartialParsingNotEnabled(DebugLevel):
    def code(self) -> str:
        return "I028"

    def message(self) -> str:
        return "Partial parsing not enabled"


class ParsedFileLoadFailed(DebugLevel):
    def code(self) -> str:
        return "I029"

    def message(self) -> str:
        return f"Failed to load parsed file from disk at {self.path}: {self.exc}"


# Skipped I030-I039


class PartialParsingEnabled(DebugLevel):
    def code(self) -> str:
        return "I040"

    def message(self) -> str:
        return (
            f"Partial parsing enabled: "
            f"{self.deleted} files deleted, "
            f"{self.added} files added, "
            f"{self.changed} files changed."
        )


class PartialParsingFile(DebugLevel):
    def code(self) -> str:
        return "I041"

    def message(self) -> str:
        return f"Partial parsing: {self.operation} file: {self.file_id}"


# Skipped I042, I043, I044, I045, I046, I047, I048, I049


class InvalidDisabledTargetInTestNode(DebugLevel):
    def code(self) -> str:
        return "I050"

    def message(self) -> str:
        target_package_string = ""

        if self.target_package != target_package_string:
            target_package_string = f"in package '{self.target_package}' "

        msg = (
            f"{self.resource_type_title} '{self.unique_id}' "
            f"({self.original_file_path}) depends on a {self.target_kind} "
            f"named '{self.target_name}' {target_package_string}which is disabled"
        )

        return warning_tag(msg)


class UnusedResourceConfigPath(WarnLevel):
    def code(self) -> str:
        return "I051"

    def message(self) -> str:
        path_list = "\n".join(f"- {u}" for u in self.unused_config_paths)
        msg = (
            "Configuration paths exist in your dbt_project.yml file which do not "
            "apply to any resources.\n"
            f"There are {len(self.unused_config_paths)} unused configuration paths:\n{path_list}"
        )
        return warning_tag(msg)


class SeedIncreased(WarnLevel):
    def code(self) -> str:
        return "I052"

    def message(self) -> str:
        msg = (
            f"Found a seed ({self.package_name}.{self.name}) "
            f">{MAXIMUM_SEED_SIZE_NAME} in size. The previous file was "
            f"<={MAXIMUM_SEED_SIZE_NAME}, so it has changed"
        )
        return msg


class SeedExceedsLimitSamePath(WarnLevel):
    def code(self) -> str:
        return "I053"

    def message(self) -> str:
        msg = (
            f"Found a seed ({self.package_name}.{self.name}) "
            f">{MAXIMUM_SEED_SIZE_NAME} in size at the same path, dvt "
            f"cannot tell if it has changed: assuming they are the same"
        )
        return msg


class SeedExceedsLimitAndPathChanged(WarnLevel):
    def code(self) -> str:
        return "I054"

    def message(self) -> str:
        msg = (
            f"Found a seed ({self.package_name}.{self.name}) "
            f">{MAXIMUM_SEED_SIZE_NAME} in size. The previous file was in "
            f"a different location, assuming it has changed"
        )
        return msg


class SeedExceedsLimitChecksumChanged(WarnLevel):
    def code(self) -> str:
        return "I055"

    def message(self) -> str:
        msg = (
            f"Found a seed ({self.package_name}.{self.name}) "
            f">{MAXIMUM_SEED_SIZE_NAME} in size. The previous file had a "
            f"checksum type of {self.checksum_name}, so it has changed"
        )
        return msg


class UnusedTables(WarnLevel):
    def code(self) -> str:
        return "I056"

    def message(self) -> str:
        msg = [
            "During parsing, dvt encountered source overrides that had no target:",
        ]
        msg += self.unused_tables
        msg.append("")
        return warning_tag("\n".join(msg))


class WrongResourceSchemaFile(WarnLevel):
    def code(self) -> str:
        return "I057"

    def message(self) -> str:
        msg = line_wrap_message(
            f"""\
            '{self.patch_name}' is a {self.resource_type} node, but it is
            specified in the {self.yaml_key} section of
            {self.file_path}.
            To fix this error, place the `{self.patch_name}`
            specification under the {self.plural_resource_type} key instead.
            """
        )
        return warning_tag(msg)


class NoNodeForYamlKey(WarnLevel):
    def code(self) -> str:
        return "I058"

    def message(self) -> str:
        msg = (
            f"Did not find matching node for patch with name '{self.patch_name}' "
            f"in the '{self.yaml_key}' section of "
            f"file '{self.file_path}'"
        )
        return warning_tag(msg)


class MacroNotFoundForPatch(WarnLevel):
    def code(self) -> str:
        return "I059"

    def message(self) -> str:
        msg = f'Found patch for macro "{self.patch_name}" which was not found'
        return warning_tag(msg)


class NodeNotFoundOrDisabled(WarnLevel):
    def code(self) -> str:
        return "I060"

    def message(self) -> str:
        # this is duplicated logic from exceptions.get_not_found_or_disabled_msg
        # when we convert exceptions to be structured maybe it can be combined?
        # converting the bool to a string since None is also valid
        if self.disabled == "None":
            reason = "was not found or is disabled"
        elif self.disabled == "True":
            reason = "is disabled"
        else:
            reason = "was not found"

        target_package_string = ""

        if self.target_package is not None:
            target_package_string = f"in package '{self.target_package}' "

        msg = (
            f"{self.resource_type_title} '{self.unique_id}' "
            f"({self.original_file_path}) depends on a {self.target_kind} "
            f"named '{self.target_name}' {target_package_string}which {reason}"
        )

        return warning_tag(msg)


class JinjaLogWarning(WarnLevel):
    def code(self) -> str:
        return "I061"

    def message(self) -> str:
        return self.msg


class JinjaLogInfo(InfoLevel):
    def code(self) -> str:
        return "I062"

    def message(self) -> str:
        # This is for the log method used in macros so msg cannot be built here
        return self.msg


class JinjaLogDebug(DebugLevel):
    def code(self) -> str:
        return "I063"

    def message(self) -> str:
        # This is for the log method used in macros so msg cannot be built here
        return self.msg


class UnpinnedRefNewVersionAvailable(InfoLevel):
    def code(self) -> str:
        return "I064"

    def message(self) -> str:
        msg = (
            f"While compiling '{self.node_info.node_name}':\n"
            f"Found an unpinned reference to versioned model '{self.ref_node_name}' in project '{self.ref_node_package}'.\n"
            f"Resolving to latest version: {self.ref_node_name}.v{self.ref_node_version}\n"
            f"A prerelease version {self.ref_max_version} is available. It has not yet been marked 'latest' by its maintainer.\n"
            f"When that happens, this reference will resolve to {self.ref_node_name}.v{self.ref_max_version} instead.\n\n"
            f"  Try out v{self.ref_max_version}: {{{{ ref('{self.ref_node_package}', '{self.ref_node_name}', v='{self.ref_max_version}') }}}}\n"
            f"  Pin to  v{self.ref_node_version}: {{{{ ref('{self.ref_node_package}', '{self.ref_node_name}', v='{self.ref_node_version}') }}}}\n"
        )
        return msg


class UpcomingReferenceDeprecation(WarnLevel):
    def code(self) -> str:
        return "I066"

    def message(self) -> str:
        ref_model_version = (
            ".v" + self.ref_model_version if self.ref_model_version else ""
        )
        msg = (
            f"While compiling '{self.model_name}': Found a reference to {self.ref_model_name}{ref_model_version}, "
            f"which is slated for deprecation on '{self.ref_model_deprecation_date}'. "
        )

        if (
            self.ref_model_version
            and self.ref_model_version != self.ref_model_latest_version
        ):
            coda = (
                f"A new version of '{self.ref_model_name}' is available. Try it out: "
                f"{{{{ ref('{self.ref_model_package}', '{self.ref_model_name}', "
                f"v='{self.ref_model_latest_version}') }}}}."
            )
            msg = msg + coda

        return warning_tag(msg)


class DeprecatedReference(WarnLevel):
    def code(self) -> str:
        return "I067"

    def message(self) -> str:
        ref_model_version = (
            ".v" + self.ref_model_version if self.ref_model_version else ""
        )
        msg = (
            f"While compiling '{self.model_name}': Found a reference to {self.ref_model_name}{ref_model_version}, "
            f"which was deprecated on '{self.ref_model_deprecation_date}'. "
        )

        if (
            self.ref_model_version
            and self.ref_model_version != self.ref_model_latest_version
        ):
            coda = (
                f"A new version of '{self.ref_model_name}' is available. Migrate now: "
                f"{{{{ ref('{self.ref_model_package}', '{self.ref_model_name}', "
                f"v='{self.ref_model_latest_version}') }}}}."
            )
            msg = msg + coda

        return warning_tag(msg)


class UnsupportedConstraintMaterialization(WarnLevel):
    def code(self) -> str:
        return "I068"

    def message(self) -> str:
        msg = (
            f"Constraint types are not supported for {self.materialized} materializations and will "
            "be ignored.  Set 'warn_unsupported: false' on this constraint to ignore this warning."
        )

        return line_wrap_message(warning_tag(msg))


class ParseInlineNodeError(ErrorLevel):
    def code(self) -> str:
        return "I069"

    def message(self) -> str:
        return "Error while parsing node: " + self.node_info.node_name + "\n" + self.exc


class SemanticValidationFailure(WarnLevel):
    def code(self) -> str:
        return "I070"

    def message(self) -> str:
        return self.msg


class UnversionedBreakingChange(WarnLevel):
    def code(self) -> str:
        return "I071"

    def message(self) -> str:
        reasons = "\n  - ".join(self.breaking_changes)

        msg = (
            f"Breaking change to contracted, unversioned model {self.model_name} ({self.model_file_path})"
            "\nWhile comparing to previous project state, dvt detected a breaking change to an unversioned model."
            f"\n  - {reasons}\n"
        )

        return warning_tag(msg)


class WarnStateTargetEqual(WarnLevel):
    def code(self) -> str:
        return "I072"

    def message(self) -> str:
        return yellow(
            f"Warning: The state and target directories are the same: '{self.state_path}'. "
            f"This could lead to missing changes due to overwritten state including non-idempotent retries."
        )


class FreshnessConfigProblem(WarnLevel):
    def code(self) -> str:
        return "I073"

    def message(self) -> str:
        return self.msg


class MicrobatchModelNoEventTimeInputs(WarnLevel):
    def code(self) -> str:
        return "I074"

    def message(self) -> str:
        msg = (
            f"The microbatch model '{self.model_name}' has no 'ref' or 'source' input with an 'event_time' configuration. "
            "\nThis means no filtering can be applied and can result in unexpected duplicate records in the resulting microbatch model."
        )

        return warning_tag(msg)


class InvalidConcurrentBatchesConfig(WarnLevel):
    def code(self) -> str:
        return "I075"

    def message(self) -> str:
        maybe_plural_count_of_models = pluralize(self.num_models, "microbatch model")
        description = f"Found {maybe_plural_count_of_models} with the `concurrent_batches` config set to true, but the {self.adapter_type} adapter does not support running batches concurrently. Batches will be run sequentially."
        return line_wrap_message(warning_tag(description))


class InvalidMacroAnnotation(WarnLevel):
    def code(self) -> str:
        return "I076"

    def message(self) -> str:
        return self.msg


class PackageNodeDependsOnRootProjectNode(WarnLevel):
    def code(self) -> str:
        return "I077"

    def message(self) -> str:
        msg = (
            f"The node '{self.node_name}'in package '{self.package_name}' depends on the root project node '{self.root_project_unique_id}'."
            "This may lead to unexpected cycles downstream. Please set the 'require_ref_prefers_node_package_to_root' behavior change flag to True to avoid this issue."
            "For more information, see the documentation at https://docs.getdbt.com/reference/global-configs/behavior-changes#require_ref_prefers_node_package_to_root"
        )
        return warning_tag(msg)


# =======================================================
# M - Deps generation
# =======================================================


class GitSparseCheckoutSubdirectory(DebugLevel):
    def code(self) -> str:
        return "M001"

    def message(self) -> str:
        return f"Subdirectory specified: {self.subdir}, using sparse checkout."


class GitProgressCheckoutRevision(DebugLevel):
    def code(self) -> str:
        return "M002"

    def message(self) -> str:
        return f"Checking out revision {self.revision}."


class GitProgressUpdatingExistingDependency(DebugLevel):
    def code(self) -> str:
        return "M003"

    def message(self) -> str:
        return f"Updating existing dependency {self.dir}."


class GitProgressPullingNewDependency(DebugLevel):
    def code(self) -> str:
        return "M004"

    def message(self) -> str:
        return f"Pulling new dependency {self.dir}."


class GitNothingToDo(DebugLevel):
    def code(self) -> str:
        return "M005"

    def message(self) -> str:
        return f"Already at {self.sha}, nothing to do."


class GitProgressUpdatedCheckoutRange(DebugLevel):
    def code(self) -> str:
        return "M006"

    def message(self) -> str:
        return f"Updated checkout from {self.start_sha} to {self.end_sha}."


class GitProgressCheckedOutAt(DebugLevel):
    def code(self) -> str:
        return "M007"

    def message(self) -> str:
        return f"Checked out at {self.end_sha}."


class RegistryProgressGETRequest(DebugLevel):
    def code(self) -> str:
        return "M008"

    def message(self) -> str:
        return f"Making package registry request: GET {self.url}"


class RegistryProgressGETResponse(DebugLevel):
    def code(self) -> str:
        return "M009"

    def message(self) -> str:
        return f"Response from registry: GET {self.url} {self.resp_code}"


class SelectorReportInvalidSelector(InfoLevel):
    def code(self) -> str:
        return "M010"

    def message(self) -> str:
        return (
            f"The '{self.spec_method}' selector specified in {self.raw_spec} is "
            f"invalid. Must be one of [{self.valid_selectors}]"
        )


class DepsNoPackagesFound(InfoLevel):
    def code(self) -> str:
        return "M013"

    def message(self) -> str:
        return "Warning: No packages were found in packages.yml"


class DepsStartPackageInstall(InfoLevel):
    def code(self) -> str:
        return "M014"

    def message(self) -> str:
        return f"Installing {self.package_name}"


class DepsInstallInfo(InfoLevel):
    def code(self) -> str:
        return "M015"

    def message(self) -> str:
        return f"Installed from {self.version_name}"


class DepsUpdateAvailable(InfoLevel):
    def code(self) -> str:
        return "M016"

    def message(self) -> str:
        return f"Updated version available: {self.version_latest}"


class DepsUpToDate(InfoLevel):
    def code(self) -> str:
        return "M017"

    def message(self) -> str:
        return "Up to date!"


class DepsListSubdirectory(InfoLevel):
    def code(self) -> str:
        return "M018"

    def message(self) -> str:
        return f"and subdirectory {self.subdirectory}"


class DepsNotifyUpdatesAvailable(InfoLevel):
    def code(self) -> str:
        return "M019"

    def message(self) -> str:
        return f"Updates available for packages: {self.packages} \
                \nUpdate your versions in packages.yml, then run dvt deps"


class RegistryIndexProgressGETRequest(DebugLevel):
    def code(self) -> str:
        return "M022"

    def message(self) -> str:
        return f"Making package index registry request: GET {self.url}"


class RegistryIndexProgressGETResponse(DebugLevel):
    def code(self) -> str:
        return "M023"

    def message(self) -> str:
        return f"Response from registry index: GET {self.url} {self.resp_code}"


class RegistryResponseUnexpectedType(DebugLevel):
    def code(self) -> str:
        return "M024"

    def message(self) -> str:
        return f"Response was None: {self.response}"


class RegistryResponseMissingTopKeys(DebugLevel):
    def code(self) -> str:
        return "M025"

    def message(self) -> str:
        # expected/actual keys logged in exception
        return f"Response missing top level keys: {self.response}"


class RegistryResponseMissingNestedKeys(DebugLevel):
    def code(self) -> str:
        return "M026"

    def message(self) -> str:
        # expected/actual keys logged in exception
        return f"Response missing nested keys: {self.response}"


class RegistryResponseExtraNestedKeys(DebugLevel):
    def code(self) -> str:
        return "M027"

    def message(self) -> str:
        # expected/actual keys logged in exception
        return f"Response contained inconsistent keys: {self.response}"


class DepsSetDownloadDirectory(DebugLevel):
    def code(self) -> str:
        return "M028"

    def message(self) -> str:
        return f"Set downloads directory='{self.path}'"


class DepsUnpinned(WarnLevel):
    def code(self) -> str:
        return "M029"

    def message(self) -> str:
        if self.revision == "HEAD":
            unpinned_msg = "not pinned, using HEAD (default branch)"
        elif self.revision in ("main", "master"):
            unpinned_msg = f'pinned to the "{self.revision}" branch'
        else:
            unpinned_msg = None

        msg = (
            f'The git package "{self.git}" \n\tis {unpinned_msg}.\n\tThis can introduce '
            f"breaking changes into your project without warning!\n\nSee {PIN_PACKAGE_URL}"
        )
        return yellow(f"WARNING: {msg}")


class NoNodesForSelectionCriteria(WarnLevel):
    def code(self) -> str:
        return "M030"

    def message(self) -> str:
        return f"The selection criterion '{self.spec_raw}' does not match any enabled nodes"


class DepsLockUpdating(InfoLevel):
    def code(self):
        return "M031"

    def message(self) -> str:
        return f"Updating lock file in file path: {self.lock_filepath}"


class DepsAddPackage(InfoLevel):
    def code(self):
        return "M032"

    def message(self) -> str:
        return f"Added new package {self.package_name}@{self.version} to {self.packages_filepath}"


class DepsFoundDuplicatePackage(InfoLevel):
    def code(self):
        return "M033"

    def message(self) -> str:
        return (
            f"Found duplicate package in packages.yml, removing: {self.removed_package}"
        )


class DepsScrubbedPackageName(WarnLevel):
    def code(self):
        return "M035"

    def message(self) -> str:
        return f"Detected secret env var in {self.package_name}. dvt will write a scrubbed representation to the lock file. This will cause issues with subsequent 'dvt deps' using the lock file, requiring 'dvt deps --upgrade'"


# =======================================================
# P - Artifacts
# =======================================================


class ArtifactWritten(DebugLevel):
    def code(self):
        return "P001"

    def message(self) -> str:
        return f"Wrote artifact {self.artifact_type} to {self.artifact_path}"


# =======================================================
# Q - Node execution
# =======================================================


class RunningOperationCaughtError(ErrorLevel):
    def code(self) -> str:
        return "Q001"

    def message(self) -> str:
        return f"Encountered an error while running operation: {self.exc}"


class CompileComplete(InfoLevel):
    def code(self) -> str:
        return "Q002"

    def message(self) -> str:
        return "Done."


class FreshnessCheckComplete(InfoLevel):
    def code(self) -> str:
        return "Q003"

    def message(self) -> str:
        return "Done."


class SeedHeader(InfoLevel):
    def code(self) -> str:
        return "Q004"

    def message(self) -> str:
        return self.header


class SQLRunnerException(DebugLevel):
    def code(self) -> str:
        return "Q006"

    def message(self) -> str:
        return f"Got an exception: {self.exc}"


class LogTestResult(DynamicLevel):
    def code(self) -> str:
        return "Q007"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR"
            status = red(
                info,
            )
        elif self.status == "pass":
            info = "PASS"
            status = green(info)
        elif self.status == "warn":
            info = f"WARN {self.num_failures}"
            status = yellow(info)
        else:  # self.status == "fail":
            info = f"FAIL {self.num_failures}"
            status = red(info)
        msg = f"{info} {self.name}"

        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.num_models,
            execution_time=self.execution_time,
        )

    @classmethod
    def status_to_level(cls, status):
        # The statuses come from TestStatus
        level_lookup = {
            "fail": EventLevel.ERROR,
            "pass": EventLevel.INFO,
            "warn": EventLevel.WARN,
            "error": EventLevel.ERROR,
        }
        if status in level_lookup:
            return level_lookup[status]
        else:
            return EventLevel.INFO


class LogNodeResult(DynamicLevel):
    def code(self) -> str:
        return "Q008"

    def message(self) -> str:
        return self.msg


# Skipped Q009, Q010


class LogStartLine(InfoLevel):
    def code(self) -> str:
        return "Q011"

    def message(self) -> str:
        msg = f"START {self.description}"
        return format_fancy_output_line(
            msg=msg, status="RUN", index=self.index, total=self.total
        )


class LogModelResult(DynamicLevel):
    def code(self) -> str:
        return "Q012"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR creating"
            status = red(self.status.upper())
        elif "PARTIAL SUCCESS" in self.status:
            info = "PARTIALLY created"
            status = yellow(self.status.upper())
        else:
            info = "OK created"
            status = green(self.status)

        msg = f"{info} {self.description}"
        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )


# Skipped Q013, Q014


class LogSnapshotResult(DynamicLevel):
    def code(self) -> str:
        return "Q015"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR snapshotting"
            status = red(self.status.upper())
        else:
            info = "OK snapshotted"
            status = green(self.result_message)

        msg = "{info} {description}".format(
            info=info, description=self.description, **self.cfg
        )
        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )


class LogSeedResult(DynamicLevel):
    def code(self) -> str:
        return "Q016"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR loading"
            status = red(self.status.upper())
        else:
            info = "OK loaded"
            status = green(self.result_message)
        msg = f"{info} seed file {self.schema}.{self.relation}"
        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )


# Skipped Q017


class LogFreshnessResult(DynamicLevel):
    def code(self) -> str:
        return "Q018"

    def message(self) -> str:
        if self.status == "runtime error":
            info = "ERROR"
            status = red(info)
        elif self.status == "error":
            info = "ERROR STALE"
            status = red(info)
        elif self.status == "warn":
            info = "WARN"
            status = yellow(info)
        else:
            info = "PASS"
            status = green(info)
        msg = f"{info} freshness of {self.source_name}.{self.table_name}"
        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )

    @classmethod
    def status_to_level(cls, status):
        # The statuses come from FreshnessStatus
        # TODO should this return EventLevel enum instead?
        level_lookup = {
            "runtime error": EventLevel.ERROR,
            "pass": EventLevel.INFO,
            "warn": EventLevel.WARN,
            "error": EventLevel.ERROR,
        }
        if status in level_lookup:
            return level_lookup[status]
        else:
            return EventLevel.INFO


class LogNodeNoOpResult(InfoLevel):
    def code(self) -> str:
        return "Q019"

    def message(self) -> str:
        msg = f"NO-OP {self.description}"
        return format_fancy_output_line(
            msg=msg,
            status=yellow("NO-OP"),
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )


# Skipped Q020, Q021


class LogCancelLine(ErrorLevel):
    def code(self) -> str:
        return "Q022"

    def message(self) -> str:
        msg = f"CANCEL query {self.conn_name}"
        return format_fancy_output_line(
            msg=msg, status=red("CANCEL"), index=None, total=None
        )


class DefaultSelector(InfoLevel):
    def code(self) -> str:
        return "Q023"

    def message(self) -> str:
        return f"Using default selector {self.name}"


class NodeStart(DebugLevel):
    def code(self) -> str:
        return "Q024"

    def message(self) -> str:
        return f"Began running node {self.node_info.unique_id}"


class NodeFinished(DebugLevel):
    def code(self) -> str:
        return "Q025"

    def message(self) -> str:
        return f"Finished running node {self.node_info.unique_id}"


class QueryCancelationUnsupported(InfoLevel):
    def code(self) -> str:
        return "Q026"

    def message(self) -> str:
        msg = (
            f"The {self.type} adapter does not support query "
            "cancellation. Some queries may still be "
            "running!"
        )
        return yellow(msg)


class ConcurrencyLine(InfoLevel):
    def code(self) -> str:
        return "Q027"

    def message(self) -> str:
        return f"Concurrency: {self.num_threads} threads (target='{self.target_name}')"


class WritingInjectedSQLForNode(DebugLevel):
    def code(self) -> str:
        return "Q029"

    def message(self) -> str:
        return f'Writing injected SQL for node "{self.node_info.unique_id}"'


class NodeCompiling(DebugLevel):
    def code(self) -> str:
        return "Q030"

    def message(self) -> str:
        return f"Began compiling node {self.node_info.unique_id}"


class NodeExecuting(DebugLevel):
    def code(self) -> str:
        return "Q031"

    def message(self) -> str:
        return f"Began executing node {self.node_info.unique_id}"


class LogHookStartLine(InfoLevel):
    def code(self) -> str:
        return "Q032"

    def message(self) -> str:
        msg = f"START hook: {self.statement}"
        return format_fancy_output_line(
            msg=msg, status="RUN", index=self.index, total=self.total, truncate=True
        )


class LogHookEndLine(InfoLevel):
    def code(self) -> str:
        return "Q033"

    def message(self) -> str:
        if self.status == "success":
            info = "OK"
            status = green(info)
        elif self.status == "skipped":
            info = "SKIP"
            status = yellow(info)
        else:
            info = "ERROR"
            status = red(info)
        msg = f"{info} hook: {self.statement}"

        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
            truncate=True,
        )


class SkippingDetails(InfoLevel):
    def code(self) -> str:
        return "Q034"

    def message(self) -> str:
        # ToDo: move to core or figure out NodeType
        if self.resource_type in ["model", "seed", "snapshot"]:
            msg = f"SKIP relation {self.schema}.{self.node_name}"
        else:
            msg = f"SKIP {self.resource_type} {self.node_name}"
        return format_fancy_output_line(
            msg=msg, status=yellow("SKIP"), index=self.index, total=self.total
        )


class NothingToDo(WarnLevel):
    def code(self) -> str:
        return "Q035"

    def message(self) -> str:
        return "Nothing to do. Try checking your model configs and model specification args"


class RunningOperationUncaughtError(ErrorLevel):
    def code(self) -> str:
        return "Q036"

    def message(self) -> str:
        return f"Encountered an error while running operation: {self.exc}"


class EndRunResult(DebugLevel):
    def code(self) -> str:
        return "Q037"

    def message(self) -> str:
        return "Command end result"


class NoNodesSelected(WarnLevel):
    def code(self) -> str:
        return "Q038"

    def message(self) -> str:
        return "No nodes selected!"


class CommandCompleted(DebugLevel):
    def code(self) -> str:
        return "Q039"

    def message(self) -> str:
        status = "succeeded" if self.success else "failed"
        completed_at = timestamp_to_datetime_string(self.completed_at)
        return f"Command `{self.command}` {status} at {completed_at} after {self.elapsed:0.2f} seconds"


class ShowNode(InfoLevel):
    def code(self) -> str:
        return "Q041"

    def message(self) -> str:
        if self.output_format == "json":
            if self.is_inline:
                return json.dumps({"show": json.loads(self.preview)}, indent=2)
            else:
                return json.dumps(
                    {"node": self.node_name, "show": json.loads(self.preview)}, indent=2
                )
        else:
            if self.quiet:
                return self.preview
            elif self.is_inline:
                return f"Previewing inline node:\n{self.preview}"
            else:
                return f"Previewing node '{self.node_name}':\n{self.preview}"


class CompiledNode(InfoLevel):
    def code(self) -> str:
        return "Q042"

    def message(self) -> str:
        if self.output_format == "json":
            if self.is_inline:
                return json.dumps({"compiled": self.compiled}, indent=2)
            else:
                return json.dumps(
                    {"node": self.node_name, "compiled": self.compiled}, indent=2
                )
        else:
            if self.quiet:
                return self.compiled
            elif self.is_inline:
                return f"Compiled inline node is:\n{self.compiled}"
            else:
                return f"Compiled node '{self.node_name}' is:\n{self.compiled}"


class SnapshotTimestampWarning(WarnLevel):
    def code(self) -> str:
        return "Q043"

    def message(self) -> str:
        return (
            f"Data type of snapshot table timestamp columns ({self.snapshot_time_data_type}) "
            f"doesn't match derived column 'updated_at' ({self.updated_at_data_type}). "
            "Please update snapshot config 'updated_at'."
        )


class MicrobatchExecutionDebug(DebugLevel):
    def code(self) -> str:
        return "Q044"

    def message(self) -> str:
        return self.msg


class LogStartBatch(InfoLevel):
    def code(self) -> str:
        return "Q045"

    def message(self) -> str:
        msg = f"START {self.description}"

        # TODO update common so that we can append "batch" in `format_fancy_output_line`
        formatted = format_fancy_output_line(
            msg=msg,
            status="RUN",
            index=self.batch_index,
            total=self.total_batches,
        )
        return f"Batch {formatted}"


class LogBatchResult(DynamicLevel):
    def code(self) -> str:
        return "Q046"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR creating"
            status = red(self.status.upper())
        elif self.status == "skipped":
            info = "SKIP"
            status = yellow(self.status.upper())
        else:
            info = "OK created"
            status = green(self.status)

        msg = f"{info} {self.description}"

        # TODO update common so that we can append "batch" in `format_fancy_output_line`
        formatted = format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.batch_index,
            total=self.total_batches,
            execution_time=self.execution_time,
        )
        return f"Batch {formatted}"


class LogFunctionResult(DynamicLevel):
    def code(self) -> str:
        return "Q047"

    def message(self) -> str:
        if self.status == "error":
            info = "ERROR creating"
            status = red(self.status.upper())
        elif self.status == "skipped":
            info = "SKIP"
            status = yellow(self.status.upper())
        else:
            info = "OK created"
            status = green(self.status.upper())

        msg = f"{info} {self.description}"
        return format_fancy_output_line(
            msg=msg,
            status=status,
            index=self.index,
            total=self.total,
            execution_time=self.execution_time,
        )


# =======================================================
# W - Node testing
# =======================================================

# Skipped W001


class CatchableExceptionOnRun(DebugLevel):
    def code(self) -> str:
        return "W002"

    def message(self) -> str:
        return str(self.exc)


class InternalErrorOnRun(DebugLevel):
    def code(self) -> str:
        return "W003"

    def message(self) -> str:
        prefix = f"Internal error executing {self.build_path}"

        internal_error_string = """This is an error in dvt. Please try again. If \
the error persists, open an issue at https://github.com/dvt-labs/dvt-ce
""".strip()

        return f"{red(prefix)}\n{str(self.exc).strip()}\n\n{internal_error_string}"


class GenericExceptionOnRun(ErrorLevel):
    def code(self) -> str:
        return "W004"

    def message(self) -> str:
        node_description = self.build_path
        if node_description is None:
            node_description = self.unique_id
        prefix = f"Unhandled error while executing {node_description}"
        return f"{red(prefix)}\n{str(self.exc).strip()}"


class NodeConnectionReleaseError(DebugLevel):
    def code(self) -> str:
        return "W005"

    def message(self) -> str:
        return f"Error releasing connection for node {self.node_name}: {str(self.exc)}"


class FoundStats(InfoLevel):
    def code(self) -> str:
        return "W006"

    def message(self) -> str:
        return f"Found {self.stat_line}"


# =======================================================
# Z - Misc
# =======================================================


class MainKeyboardInterrupt(InfoLevel):
    def code(self) -> str:
        return "Z001"

    def message(self) -> str:
        return "ctrl-c"


class MainEncounteredError(ErrorLevel):
    def code(self) -> str:
        return "Z002"

    def message(self) -> str:
        return f"Encountered an error:\n{self.exc}"


class MainStackTrace(ErrorLevel):
    def code(self) -> str:
        return "Z003"

    def message(self) -> str:
        return self.stack_trace


# Skipped Z004


class TimingInfoCollected(DebugLevel):
    def code(self) -> str:
        return "Z010"

    def message(self) -> str:
        started_at = timestamp_to_datetime_string(self.timing_info.started_at)
        completed_at = timestamp_to_datetime_string(self.timing_info.completed_at)
        return f"Timing info for {self.node_info.unique_id} ({self.timing_info.name}): {started_at} => {completed_at}"


# This prints the stack trace at the debug level while allowing just the nice exception message
# at the error level - or whatever other level chosen.  Used in multiple places.


class LogDebugStackTrace(DebugLevel):
    def code(self) -> str:
        return "Z011"

    def message(self) -> str:
        return f"{self.exc_info}"


# We don't write "clean" events to the log, because the clean command
# may have removed the log directory.


class CheckCleanPath(InfoLevel):
    def code(self) -> str:
        return "Z012"

    def message(self) -> str:
        return f"Checking {self.path}/*"


class ConfirmCleanPath(InfoLevel):
    def code(self) -> str:
        return "Z013"

    def message(self) -> str:
        return f"Cleaned {self.path}/*"


class ProtectedCleanPath(InfoLevel):
    def code(self) -> str:
        return "Z014"

    def message(self) -> str:
        return f"ERROR: not cleaning {self.path}/* because it is protected"


class FinishedCleanPaths(InfoLevel):
    def code(self) -> str:
        return "Z015"

    def message(self) -> str:
        return "Finished cleaning all paths."


class OpenCommand(InfoLevel):
    def code(self) -> str:
        return "Z016"

    def message(self) -> str:
        msg = f"""To view your profiles.yml file, run:

{self.open_cmd} {self.profiles_dir}"""

        return msg


class RunResultWarning(WarnLevel):
    def code(self) -> str:
        return "Z021"

    def message(self) -> str:
        info = "Warning"
        return yellow(f"{info} in {self.resource_type} {self.node_name} ({self.path})")


class RunResultFailure(ErrorLevel):
    def code(self) -> str:
        return "Z022"

    def message(self) -> str:
        info = "Failure"
        return red(f"{info} in {self.resource_type} {self.node_name} ({self.path})")


class StatsLine(InfoLevel):
    def code(self) -> str:
        return "Z023"

    def message(self) -> str:
        stats_line = "Done. PASS={pass} WARN={warn} ERROR={error} SKIP={skip} NO-OP={noop} TOTAL={total}"
        return stats_line.format(**self.stats)


class RunResultError(ErrorLevel):
    def code(self) -> str:
        return "Z024"

    def message(self) -> str:
        # This is the message on the result object, cannot be built here
        return f"  {self.msg}"


class RunResultErrorNoMessage(ErrorLevel):
    def code(self) -> str:
        return "Z025"

    def message(self) -> str:
        return f"  Status: {self.status}"


class SQLCompiledPath(InfoLevel):
    def code(self) -> str:
        return "Z026"

    def message(self) -> str:
        return f"  compiled code at {self.path}"


class CheckNodeTestFailure(InfoLevel):
    def code(self) -> str:
        return "Z027"

    def message(self) -> str:
        msg = f"select * from {self.relation_name}"
        border = "-" * len(msg)
        return f"  See test failures:\n  {border}\n  {msg}\n  {border}"


# Skipped Z028, Z029


class EndOfRunSummary(InfoLevel):
    def code(self) -> str:
        return "Z030"

    def message(self) -> str:
        error_plural = pluralize(self.num_errors, "error")
        warn_plural = pluralize(self.num_warnings, "warning")
        partial_success_plural = f"""{self.num_partial_success} partial {"success" if self.num_partial_success == 1 else "successes"}"""

        if self.keyboard_interrupt:
            message = yellow("Exited because of keyboard interrupt")
        elif self.num_errors > 0:
            message = red(
                f"Completed with {error_plural}, {partial_success_plural}, and {warn_plural}:"
            )
        elif self.num_partial_success > 0:
            message = yellow(
                f"Completed with {partial_success_plural} and {warn_plural}"
            )
        elif self.num_warnings > 0:
            message = yellow(f"Completed with {warn_plural}:")
        else:
            message = green("Completed successfully")
        return message


# Skipped Z031, Z032


class MarkSkippedChildren(DebugLevel):
    def code(self) -> str:
        return "Z033"

    def message(self) -> str:
        msg = (
            f"Marking all children of '{self.unique_id}' to be skipped "
            f"because of status '{self.status}'. "
        )
        if self.run_result.message:
            msg = msg + f" Reason: {self.run_result.message}."
        return msg


class LogSkipBecauseError(ErrorLevel):
    def code(self) -> str:
        return "Z034"

    def message(self) -> str:
        msg = f"SKIP relation {self.schema}.{self.relation} due to ephemeral model status '{self.status}'"
        return format_fancy_output_line(
            msg=msg, status=red("ERROR SKIP"), index=self.index, total=self.total
        )


# Skipped Z035


class EnsureGitInstalled(ErrorLevel):
    def code(self) -> str:
        return "Z036"

    def message(self) -> str:
        return (
            "Make sure git is installed on your machine. More "
            "information: "
            "https://docs.getdbt.com/docs/package-management"
        )


class DepsCreatingLocalSymlink(DebugLevel):
    def code(self) -> str:
        return "Z037"

    def message(self) -> str:
        return "Creating symlink to local dependency."


class DepsSymlinkNotAvailable(DebugLevel):
    def code(self) -> str:
        return "Z038"

    def message(self) -> str:
        return "Symlinks are not available on this OS, copying dependency."


class DisableTracking(DebugLevel):
    def code(self) -> str:
        return "Z039"

    def message(self) -> str:
        return (
            "Error sending anonymous usage statistics. Disabling tracking for this execution. "
            "If you wish to permanently disable tracking, see: "
            "https://docs.getdbt.com/reference/global-configs#send-anonymous-usage-stats."
        )


class SendingEvent(DebugLevel):
    def code(self) -> str:
        return "Z040"

    def message(self) -> str:
        return f"Sending event: {self.kwargs}"


class SendEventFailure(DebugLevel):
    def code(self) -> str:
        return "Z041"

    def message(self) -> str:
        return "An error was encountered while trying to send an event"


class FlushEvents(DebugLevel):
    def code(self) -> str:
        return "Z042"

    def message(self) -> str:
        return "Flushing usage events"


class FlushEventsFailure(DebugLevel):
    def code(self) -> str:
        return "Z043"

    def message(self) -> str:
        return "An error was encountered while trying to flush usage events"


class TrackingInitializeFailure(DebugLevel):
    def code(self) -> str:
        return "Z044"

    def message(self) -> str:
        return "Got an exception trying to initialize tracking"


# this is the message from the result object


class RunResultWarningMessage(WarnLevel):
    def code(self) -> str:
        return "Z046"

    def message(self) -> str:
        # This is the message on the result object, cannot be formatted in event
        return self.msg


class DebugCmdOut(InfoLevel):
    def code(self) -> str:
        return "Z047"

    def message(self) -> str:
        return self.msg


class DebugCmdResult(InfoLevel):
    def code(self) -> str:
        return "Z048"

    def message(self) -> str:
        return self.msg


class ListCmdOut(InfoLevel):
    # No longer in use, switching to Z051 PrintEvent in dbt-common
    def code(self) -> str:
        return "Z049"

    def message(self) -> str:
        return self.msg


class ResourceReport(DebugLevel):
    def code(self) -> str:
        return "Z051"

    def message(self) -> str:
        return f"Resource report: {self.to_json()}"


# Artifact Upload Events #


class ArtifactUploadError(ErrorLevel):
    def code(self) -> str:
        return "Z061"

    def message(self) -> str:
        return f"Error uploading artifacts to artifact ingestion API: {self.msg}"


class ArtifactUploadSuccess(InfoLevel):
    def code(self) -> str:
        return "Z062"

    def message(self) -> str:
        return f"Artifacts uploaded successfully to artifact ingestion API: {self.msg}"


class ArtifactUploadSkipped(DebugLevel):
    def code(self) -> str:
        return "Z063"

    def message(self) -> str:
        return f"Artifacts skipped for command : {self.msg}"


# =======================================================
# F - Federation (DVT-specific)
# =======================================================


class FederationPathResolved(InfoLevel):
    """Logged when federation resolver determines execution path for a model."""

    def code(self) -> str:
        return "F001"

    def message(self) -> str:
        path_display = (
            "FEDERATION" if self.execution_path == "spark_federation" else "PUSHDOWN"
        )
        return f"Resolved {self.model_name}: {path_display} (target={self.target})"


class FederationSparkInitialized(InfoLevel):
    """Logged when Spark session is initialized for federation."""

    def code(self) -> str:
        return "F002"

    def message(self) -> str:
        return f"Initialized Spark session for federation ({self.num_models} models require federation)"


class FederationSparkShutdown(DebugLevel):
    """Logged when Spark session is shut down after federation."""

    def code(self) -> str:
        return "F003"

    def message(self) -> str:
        return "Spark session shutdown complete"


class FederationSourceExtractStart(InfoLevel):
    """Logged when starting to extract a source for federation."""

    def code(self) -> str:
        return "F010"

    def message(self) -> str:
        return (
            f"Extracting source {self.source_name}.{self.table_name} from {self.target}"
        )


class FederationSourceExtractComplete(InfoLevel):
    """Logged when source extraction completes."""

    def code(self) -> str:
        return "F011"

    def message(self) -> str:
        rows_info = f" ({self.row_count} rows)" if self.row_count else ""
        return f"Extracted {self.source_name}.{self.table_name}{rows_info} in {self.elapsed:.2f}s"


class FederationSourceExtractSkipped(DebugLevel):
    """Logged when source extraction is skipped (already cached)."""

    def code(self) -> str:
        return "F012"

    def message(self) -> str:
        return f"Skipped extraction for {self.source_name}.{self.table_name} (cached, reason: {self.reason})"


class FederationSourceExtractError(ErrorLevel):
    """Logged when source extraction fails."""

    def code(self) -> str:
        return "F013"

    def message(self) -> str:
        return f"Failed to extract {self.source_name}.{self.table_name}: {self.error}"


class FederationPredicatePushdown(DebugLevel):
    """Logged when predicates are pushed down to source extraction."""

    def code(self) -> str:
        return "F014"

    def message(self) -> str:
        predicates = ", ".join(self.predicates) if self.predicates else "none"
        return f"Pushdown for {self.source_name}: predicates=[{predicates}], limit={self.limit}"


class FederationTempViewRegistered(DebugLevel):
    """Logged when a temp view is registered in Spark."""

    def code(self) -> str:
        return "F020"

    def message(self) -> str:
        return f"Registered Spark temp view: {self.view_name}"


class FederationTempViewCleanup(DebugLevel):
    """Logged when temp views are cleaned up."""

    def code(self) -> str:
        return "F021"

    def message(self) -> str:
        return f"Cleaned up {self.view_count} temp view(s) for model {self.model_name}"


class FederationSQLTranslation(DebugLevel):
    """Logged when SQL is translated to Spark dialect."""

    def code(self) -> str:
        return "F030"

    def message(self) -> str:
        return f"Translated SQL from {self.source_dialect} to Spark for model {self.model_name}"


class FederationSQLTranslationError(ErrorLevel):
    """Logged when SQL translation fails."""

    def code(self) -> str:
        return "F031"

    def message(self) -> str:
        return f"SQL translation failed for {self.model_name}: {self.error}"


class FederationExecutionStart(InfoLevel):
    """Logged when federation execution starts for a model."""

    def code(self) -> str:
        return "F040"

    def message(self) -> str:
        sources_info = f" ({self.num_sources} sources)" if self.num_sources else ""
        return f"Starting federation for {self.model_name}{sources_info}"


class FederationExecutionComplete(InfoLevel):
    """Logged when federation execution completes."""

    def code(self) -> str:
        return "F041"

    def message(self) -> str:
        return f"Federation complete for {self.model_name} in {self.elapsed:.2f}s"


class FederationExecutionError(ErrorLevel):
    """Logged when federation execution fails."""

    def code(self) -> str:
        return "F042"

    def message(self) -> str:
        return red(f"Federation failed for {self.model_name}: {self.error}")


class FederationWriteStart(DebugLevel):
    """Logged when starting to write federation results to target."""

    def code(self) -> str:
        return "F050"

    def message(self) -> str:
        return (
            f"Writing federation results to {self.target}.{self.schema}.{self.relation}"
        )


class FederationWriteComplete(DebugLevel):
    """Logged when federation write completes."""

    def code(self) -> str:
        return "F051"

    def message(self) -> str:
        rows_info = f" ({self.row_count} rows)" if self.row_count else ""
        return f"Wrote{rows_info} to {self.target}.{self.schema}.{self.relation} in {self.elapsed:.2f}s"


class FederationWriteError(ErrorLevel):
    """Logged when federation write fails."""

    def code(self) -> str:
        return "F052"

    def message(self) -> str:
        return f"Failed to write to {self.target}: {self.error}"


class FederationMaterializationCoercion(WarnLevel):
    """Logged when materialization is coerced due to cross-target federation."""

    def code(self) -> str:
        return "F060"

    def message(self) -> str:
        return warning_tag(
            f"Model {self.model_name}: materialization coerced from '{self.original}' to "
            f"'{self.coerced}' (cross-target federation cannot create views)"
        )


class FederationIncrementalExtract(InfoLevel):
    """Logged when performing incremental extraction."""

    def code(self) -> str:
        return "F070"

    def message(self) -> str:
        return (
            f"Incremental extraction for {self.source_name}: "
            f"{self.new_rows} new, {self.changed_rows} changed, {self.deleted_rows} deleted"
        )


class FederationSchemaChange(WarnLevel):
    """Logged when source schema has changed since last extraction."""

    def code(self) -> str:
        return "F071"

    def message(self) -> str:
        return warning_tag(
            f"Schema changed for {self.source_name}.{self.table_name}, performing full extraction"
        )


class FederationBucketOperation(DebugLevel):
    """Logged for bucket staging operations."""

    def code(self) -> str:
        return "F080"

    def message(self) -> str:
        return f"Bucket {self.operation}: {self.bucket_name}/{self.path}"


class FederationSummary(InfoLevel):
    """Logged at end of run with federation statistics."""

    def code(self) -> str:
        return "F099"

    def message(self) -> str:
        return (
            f"Federation summary: {self.federation_models} federated, "
            f"{self.pushdown_models} pushdown, {self.sources_extracted} sources extracted"
        )
