from __future__ import annotations

import collections
import copy
import dataclasses
import traceback
from collections import defaultdict
from enum import Enum
from typing import Any, Collection, DefaultDict, Dict, List, Literal, Mapping, Optional, Tuple, Union

import pyarrow as pa

from chalk._gen.chalk.artifacts.v1 import export_pb2
from chalk._gen.chalk.artifacts.v1.export_pb2 import (
    MetadataSettings,
    ProjectSettings,
    ValidationLog,
    ValidationSettings,
)
from chalk._gen.chalk.graph.v1 import graph_pb2 as graph_pb
from chalk._gen.chalk.graph.v1.graph_pb2 import ResolverKind
from chalk._lsp.error_builder import FeatureClassErrorBuilder, LSPErrorBuilder, ResolverErrorBuilder
from chalk.features import FeatureConverter, FeatureSetBase
from chalk.features.pseudofeatures import PSEUDONAMESPACE
from chalk.features.resolver import RESOLVER_REGISTRY
from chalk.parsed._proto.utils import (
    RESOLVER_ENUM_TO_KIND,
    get_feature_reference_fqn,
    get_feature_type_attribute_name,
    get_feature_type_etl_offline_to_online,
    get_feature_type_fqn,
    get_feature_type_is_autogenerated,
    get_feature_type_max_staleness,
    get_feature_type_name,
    get_feature_type_namespace,
)
from chalk.utils.string import add_quotes, oxford_comma_list, to_camel_case


@dataclasses.dataclass
class ProtoField:
    name: str
    has_presence: bool


_METADATA_STRING_TO_FEATURE_ATTRIBUTE_MAP: Dict[str, ProtoField] = {
    "owner": ProtoField(name="owner", has_presence=True),
    "tags": ProtoField(name="tags", has_presence=False),
    "description": ProtoField(name="description", has_presence=True),
}

_METADATA_STRING_TO_RESOLVER_ATTRIBUTE_MAP: Dict[str, ProtoField] = {
    "owner": ProtoField(name="owner", has_presence=True),
    "tags": ProtoField(name="tags", has_presence=False),
    "description": ProtoField(name="doc", has_presence=True),
    "doc": ProtoField(name="doc", has_presence=True),
    "docstring": ProtoField(name="doc", has_presence=True),
}


class GraphLogSeverity(str, Enum):
    ERROR = "ERROR"
    WARNING = "WARNING"
    INFO = "INFO"


MissingMetadata = Dict[GraphLogSeverity, List[str]]


@dataclasses.dataclass
class MissingMetadataFeature:
    feature: graph_pb.FeatureType
    missing_metadata: MissingMetadata
    namespace_path: str


@dataclasses.dataclass
class MissingMetadataResolver:
    resolver: graph_pb.Resolver
    missing_metadata: MissingMetadata


class _LogBuilder:
    _severity_to_enum = {
        GraphLogSeverity.ERROR.value: export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        GraphLogSeverity.WARNING.value: export_pb2.VALIDATION_LOG_SEVERITY_WARNING,
        GraphLogSeverity.INFO.value: export_pb2.VALIDATION_LOG_SEVERITY_INFO,
    }

    def __init__(self):
        super().__init__()
        self._logs: List[ValidationLog] = []

    def add(self, log: ValidationLog):
        self._logs.append(log)

    def add_log(self, header: str, subheader: str, severity: GraphLogSeverity):
        if severity not in self._severity_to_enum:
            raise ValueError(f"Unknown severity: {severity}")
        self._logs.append(ValidationLog(header=header, subheader=subheader, severity=self._severity_to_enum[severity]))

    def add_error(self, header: str, subheader: str):
        self.add_log(header=header, subheader=subheader, severity=GraphLogSeverity.ERROR)

    def add_warning(self, header: str, subheader: str):
        self.add_log(header=header, subheader=subheader, severity=GraphLogSeverity.WARNING)

    def add_info(self, header: str, subheader: str):
        self.add_log(header=header, subheader=subheader, severity=GraphLogSeverity.INFO)

    def get_logs(self) -> List[ValidationLog]:
        return self._logs


class ValidationLogs:
    @staticmethod
    def duplicate_feature_names(fqn: str, count: int) -> ValidationLog:
        return ValidationLog(
            header="Duplicate feature names",
            subheader=(
                f'There are {count} features with the same name of "{fqn}". All features require a ' f"distinct name"
            ),
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def no_pkey(namespace: str) -> ValidationLog:
        return ValidationLog(
            header=f"Feature set '{namespace}' is missing a primary feature",
            subheader=f"Please add an 'int' or 'str' feature to '{namespace}', annotated with '= feature(primary=True)'",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def multiple_pkeys(namespace: str, names: List[str]) -> ValidationLog:
        names_joined = ", ".join(names)
        return ValidationLog(
            header=f"Feature set '{namespace}' has too many primary features",
            subheader=f"Found primary features: {names_joined}. Composite primary keys are not supported. Please mark only a single feature as primary.",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def max_staleness_expected_with_etl_to_online(fqn: str) -> ValidationLog:
        return ValidationLog(
            header=f'Missing max staleness for "{fqn}"',
            subheader=f'The feature "{fqn}" is set to ETL to online, but doesn\'t specify a max staleness. Any ETL to online would be immediately invalidated.',
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def feature_name_protected(fqn: str) -> ValidationLog:
        return ValidationLog(
            header="Feature uses protected name",
            subheader=f"Feature names cannot begin with '_chalk' or '__'. Found '{fqn}'",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def namespace_protected(namespace: str, name: str) -> ValidationLog:
        return ValidationLog(
            header="Feature class uses protected namespace",
            subheader=(
                f'The feature "{namespace}.{name}" belongs to the protected namespace "{namespace}". '
                f'Feature namespaces cannot begin with "_chalk" or "__". Please rename this feature set.'
            ),
        )

    @staticmethod
    def duplicate_resolver_name(count: int, name: str) -> ValidationLog:
        return ValidationLog(
            header="Duplicate resolver names",
            subheader=(
                f'There are {count} resolvers with the same name of "{name}". All resolvers require a ' f"distinct name"
            ),
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def multiple_input_namespaces(fqn: str, namespaces: List[str], resolver_definition: str) -> ValidationLog:
        return ValidationLog(
            header=f'Resolver "{fqn}" requires features from multiple namespaces.',
            subheader=f"""All inputs to a resolver should be rooted in the same namespace.
The resolver "{fqn}" takes inputs in the namespaces {oxford_comma_list(add_quotes(namespaces))}.

If you require features from many feature classes, reference them via their relationships, such as:
  User.bank_account.title,
  User.full_name

{resolver_definition}
""",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def no_outputs(fqn: str) -> ValidationLog:
        return ValidationLog(
            header=f'Resolver "{fqn}" does not define outputs.',
            subheader="See https://docs.chalk.ai/docs/python-resolvers#outputs for information about valid resolver outputs.",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def multiple_df_outputs(fqn: str) -> ValidationLog:
        return ValidationLog(
            header=f'Resolver "{fqn}" defines multiple DataFrames as output.',
            subheader="See https://docs.chalk.ai/docs/python-resolvers#outputs for information about valid resolver outputs.",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def mixed_relationship_and_scalar_outputs(fqn: str) -> ValidationLog:
        return ValidationLog(
            header=f'Resolver "{fqn}" returns both relationships and scalar features.',
            subheader="See https://docs.chalk.ai/docs/python-resolvers#outputs for information about valid resolver outputs.",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def invalid_feature_time_input(resolver_fqn: str, resolver_kind: ResolverKind, feature_fqn: str) -> ValidationLog:
        kind = RESOLVER_ENUM_TO_KIND.get(resolver_kind, "unknown")
        return ValidationLog(
            header=f"Feature times cannot be accepted as input to {kind} resolvers.",
            subheader=(
                f'The resolver "{resolver_fqn}" takes as input the\n'
                f'feature "{feature_fqn}", a feature_time() type.\n'
                f"Feature times can be returned from {kind} resolvers to\n"
                "indicate data from the past, but cannot be accepted\n"
                "as inputs."
            ),
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def unrecognized_input(resolver_fqn: str, feature_fqn: str) -> ValidationLog:
        return ValidationLog(
            header=f"Resolver '{resolver_fqn}' requires an unknown feature '{feature_fqn}'",
            subheader="Please check the inputs to this resolver, and make sure they are all features.",
            severity=export_pb2.VALIDATION_LOG_SEVERITY_ERROR,
        )

    @staticmethod
    def invalid_severity(severity: str, metadata_name: str) -> ValidationLog:
        severities_lower = [e.lower() for e in GraphLogSeverity]
        severity_choices = '" or "'.join(severities_lower)
        return ValidationLog(
            header=f'Found invalid log severity "{severity}" config for missing metadata',
            subheader=(
                f'The required feature metadata "{metadata_name}" is associated with an invalid log severity "{severity}".'
                f' Please use "{severity_choices}" in chalk.yml'
            ),
            severity=export_pb2.VALIDATION_LOG_SEVERITY_WARNING,
        )

    @staticmethod
    def invalid_metadata_name(metadata_name: str, entity: str) -> ValidationLog:
        return ValidationLog(
            header=f'Found invalid feature metadata "{metadata_name}" in config',
            subheader=(
                f'The required metadata "{metadata_name}" is not a valid {entity} metadata.'
                f" Please consider removing it from chalk.yml"
            ),
            severity=export_pb2.VALIDATION_LOG_SEVERITY_WARNING,
        )


def _validate_primary_key(
    feature: graph_pb.FeatureType,
    builder: _LogBuilder,
    lsp_builder: FeatureClassErrorBuilder,
):
    if not feature.HasField("scalar") or not feature.scalar.is_primary:
        return

    fqn = get_feature_type_fqn(feature)
    if not feature.scalar.HasField("arrow_type"):
        raise ValueError(f"The serialized dtype is not set for feature '{fqn}'")
    pa_dtype = FeatureConverter.convert_proto_dtype_to_pa_dtype(feature.scalar.arrow_type)
    if (
        not pa.types.is_integer(pa_dtype)
        and not pa.types.is_large_string(pa_dtype)
        and not pa.types.is_string(pa_dtype)
    ):
        attr_name = get_feature_type_attribute_name(feature)
        lsp_builder.add_diagnostic(
            label="invalid type",
            message=f"Primary keys must be integers or strings. Feature '{attr_name}' has a type of '{pa_dtype}'",
            range=lsp_builder.annotation_range(attr_name),
            code="23",
        )
        builder.add_error(
            header=f'Invalid primary key type "{fqn}"',
            subheader=(f"Primary keys must be integers or strings. " f'Feature "{fqn}" has a type of {pa_dtype}'),
        )


def _validate_etl_offline_to_online(
    feature_set: graph_pb.FeatureSet,
    feature: graph_pb.FeatureType,
    builder: _LogBuilder,
):
    if (feature.HasField("scalar") and feature.scalar.is_primary) or feature.HasField("feature_time"):
        return

    # We want to resolve this before do validate
    # in case `max_staleness` or `etl_offline_to_online`
    # is set on the feature set, but not on individual
    # features.
    eoto = get_feature_type_etl_offline_to_online(feature)
    if eoto is None:
        eoto = feature_set.etl_offline_to_online if feature_set.HasField("etl_offline_to_online") else None

    max_staleness = get_feature_type_max_staleness(feature)
    if max_staleness is None:
        max_staleness = feature_set.max_staleness_duration if feature_set.HasField("max_staleness_duration") else None

    if eoto and max_staleness is None:
        fqn = get_feature_type_fqn(feature)
        builder.add(ValidationLogs.max_staleness_expected_with_etl_to_online(fqn))


def _validate_no_feature_times_as_input(
    fqn_to_feature: Mapping[str, graph_pb.FeatureType],
    resolver: graph_pb.Resolver,
    builder: _LogBuilder,
    lsp_builder: ResolverErrorBuilder,
):
    for i, inp in enumerate(resolver.inputs):
        if not inp.HasField("feature"):
            continue
        if inp.feature.feature.namespace == PSEUDONAMESPACE:
            continue
        fqn = get_feature_reference_fqn(inp.feature.feature)
        if fqn not in fqn_to_feature:
            # We might not ever come in here because `convert_graph` should have validated this.
            lsp_builder.add_diagnostic(
                message=f"Resolver '{resolver.fqn}' requires an unrecognized feature '{fqn}'",
                code="135",
                range=lsp_builder.function_arg_annotation_by_index(i),
                label="invalid resolver input",
            )
            builder.add(ValidationLogs.unrecognized_input(resolver.fqn, fqn))
        elif fqn_to_feature[fqn].HasField("feature_time") and resolver.kind != graph_pb.RESOLVER_KIND_OFFLINE:
            lsp_builder.add_diagnostic(
                message=(
                    f'The resolver "{resolver.fqn}" takes as input the\n'
                    f'feature "{fqn}", a feature_time() type.\n'
                    f"Feature times can be returned from {resolver.kind} resolvers to\n"
                    "indicate data from the past, but cannot be accepted\n"
                    "as inputs."
                ),
                code="136",
                range=lsp_builder.function_arg_annotation_by_index(i),
                label="invalid resolver input",
            )
            builder.add(ValidationLogs.invalid_feature_time_input(resolver.fqn, resolver.kind, fqn))


def _validate_feature_names_unique(features: List[graph_pb.FeatureType], builder: _LogBuilder):
    counter: DefaultDict[Tuple[str, str], int] = defaultdict(lambda: 0)
    for r in features:
        # Need to also index on `attribute_name` because protos store the features generated
        # for versioned features with the same name but different attribute names.
        # CHA-2703 might change the need for this handling.
        counter[get_feature_type_fqn(r), get_feature_type_attribute_name(r)] += 1

    for fqn_and_attr_name, count in counter.items():
        fqn, _ = fqn_and_attr_name
        if count > 1:
            builder.add(ValidationLogs.duplicate_feature_names(fqn, count))


def _validate_resolver_names_unique(resolvers: List[graph_pb.Resolver], builder: _LogBuilder):
    counter: DefaultDict[str, int] = defaultdict(lambda: 0)
    for r in resolvers:
        counter[r.fqn.split(".")[-1]] += 1

    for name, count in counter.items():
        if count > 1:
            builder.add(ValidationLogs.duplicate_resolver_name(count, name))


def _validate_resolver_input(
    singleton_namespaces: set[str],
    resolver: graph_pb.Resolver,
    builder: _LogBuilder,
    lsp_builder: ResolverErrorBuilder,
):
    namespaces = []
    ns_to_arg_num: Dict[str, int] = {}

    for idx, input_ in enumerate(resolver.inputs):
        if input_.HasField("feature"):
            f = input_.feature.feature
            namespace = f.namespace
            if len(f.path) > 0:
                namespace = f.path[0].namespace

            if namespace == PSEUDONAMESPACE:
                continue

            if namespace not in singleton_namespaces:
                ns_to_arg_num[namespace] = idx
                if namespace not in namespaces:
                    namespaces.append(namespace)
        elif input_.HasField("df"):
            # TODO: Validate dataframe
            continue
        elif input_.HasField("state"):
            continue
        else:
            raise ValueError(f"Unknown input type: {input_}")

    if len(namespaces) > 1:
        arg_num = list(ns_to_arg_num.values())[0]
        d = lsp_builder.add_diagnostic(
            message=f"""All inputs to a resolver should be rooted in the same namespace.
The resolver "{resolver.fqn}" takes inputs in the namespaces {oxford_comma_list(add_quotes(namespaces))}.

If you require features from many feature classes, reference them via their relationships, such as:
  User.bank_account.title,
  User.full_name""",
            code="131",
            range=lsp_builder.function_arg_annotation_by_index(arg_num),
            label="secondary namespace",
        )
        for ns, arg in ns_to_arg_num.items():
            if arg != arg_num:
                d.with_range(
                    lsp_builder.function_arg_annotation_by_index(arg),
                    label=f"other namespace {ns}",
                )

        builder.add(
            ValidationLogs.multiple_input_namespaces(
                fqn=resolver.fqn,
                namespaces=namespaces,
                resolver_definition=resolver.function.function_definition,
            )
        )


def _validate_resolver_output(resolver: graph_pb.Resolver, builder: _LogBuilder, lsp_builder: ResolverErrorBuilder):
    for o in resolver.outputs:
        if not o.HasField("df") and not o.HasField("feature"):
            raise ValueError(f"Unexpected output type: {o}")

    features: List[graph_pb.FeatureReference] = [o.feature for o in resolver.outputs if o.HasField("feature")]
    dataframes: List[graph_pb.DataFrameType] = [o.df for o in resolver.outputs if o.HasField("df")]

    if len(features) == 0 and len(dataframes) == 0:
        lsp_builder.add_diagnostic(
            message=f'Resolver "{resolver.fqn}" does not define outputs. All resolvers must have an output',
            code="132",
            range=lsp_builder.function_return_annotation(),
            label="invalid resolver output",
            code_href="https://docs.chalk.ai/docs/python-resolvers#outputs",
        )
        builder.add(ValidationLogs.no_outputs(resolver.fqn))
    if len(dataframes) > 1:
        lsp_builder.add_diagnostic(
            message=f'Resolver "{resolver.fqn}" defines multiple DataFrames as output. At most one is permitted',
            code="133",
            range=lsp_builder.function_return_annotation(),
            label="invalid resolver output",
            code_href="https://docs.chalk.ai/docs/python-resolvers#outputs",
        )
        builder.add(ValidationLogs.multiple_df_outputs(resolver.fqn))
    if len(features) > 0 and len(dataframes) > 0:
        lsp_builder.add_diagnostic(
            message=(
                f'Resolver "{resolver.fqn}" returns both relationships and scalar features. '
                f"Only one or the other is permitted."
            ),
            code="134",
            range=lsp_builder.function_return_annotation(),
            label="invalid resolver output",
            code_href="https://docs.chalk.ai/docs/python-resolvers#outputs",
        )
        builder.add(ValidationLogs.mixed_relationship_and_scalar_outputs(resolver.fqn))


def _validate_feature_names(feature: graph_pb.FeatureType, builder: _LogBuilder, lsp_builder: FeatureClassErrorBuilder):
    namespace = get_feature_type_namespace(feature)
    if namespace == PSEUDONAMESPACE:
        return
    name = get_feature_type_name(feature)
    if name.startswith("__") or name.startswith("_chalk"):
        lsp_builder.add_diagnostic(
            message="Feature names cannot begin with '_chalk' or '__'.",
            range=lsp_builder.property_range(get_feature_type_attribute_name(feature)),
            label="protected name",
            code="24",
        )
        builder.add(ValidationLogs.feature_name_protected(f"{namespace}.{name}"))
    if namespace.startswith("__") or namespace.startswith("_chalk"):
        lsp_builder.add_diagnostic(
            message="Feature classes cannot have names that begin with '_chalk' or '__'.",
            label="protected namespace",
            range=lsp_builder.decorator_kwarg_value_range("name") or lsp_builder.class_definition_range(),
            code="25",
        )
        builder.add(ValidationLogs.namespace_protected(namespace, name))


def _get_field_value(
    entity: Union[graph_pb.FeatureType, graph_pb.Resolver],
    field: ProtoField,
) -> Optional[Any]:
    def get_field_with_presence_considered(
        underlying: Union[
            graph_pb.ScalarFeatureType,
            graph_pb.HasOneFeatureType,
            graph_pb.HasManyFeatureType,
            graph_pb.FeatureTimeFeatureType,
            graph_pb.WindowedFeatureType,
            graph_pb.Resolver,
        ],
    ):
        if not hasattr(underlying, field.name):
            # Important to raise AttributeError first before checking whether `underlying.HasField`
            # so that we raise AttributeError instead of whatever error `HasField` would raise
            raise AttributeError(f"Entity {underlying} does not have attribute {field.name}")
        if field.has_presence and not underlying.HasField(field.name):
            return None
        return getattr(underlying, field.name)

    if isinstance(entity, graph_pb.FeatureType):
        feature = getattr(entity, entity.WhichOneof("type"))
        return get_field_with_presence_considered(feature)
    elif isinstance(entity, graph_pb.Resolver):  # pyright: ignore[reportUnnecessaryIsInstance]
        return get_field_with_presence_considered(entity)
    else:
        raise ValueError(f"Unknown entity type: {entity}")


def _get_missing_metadata(
    entity: Union[graph_pb.FeatureType, graph_pb.Resolver],
    attribute_map: Dict[str, ProtoField],
    entity_type: Literal["feature", "resolver"],
    settings: Collection[MetadataSettings],
    builder: _LogBuilder,
) -> MissingMetadata:
    if isinstance(entity, graph_pb.FeatureType) and entity.HasField("windowed"):
        # TODO: We store windowed features metadata in the pseudofeatures.
        #       instead of the base feature. This might change with CHA-3194.
        return {}

    res: MissingMetadata = collections.defaultdict(list)
    for setting in settings:
        metadata_name = setting.name
        try:
            severity = GraphLogSeverity(setting.missing.upper())
        except:
            """If this fails, _validate_metadata_config should log the error"""
            continue
        if metadata_name in attribute_map:
            attribute_name = attribute_map[metadata_name]
            value = None

            try:
                value = _get_field_value(entity, attribute_name)
            except AttributeError:
                builder.add_warning(
                    header=f'Found invalid {entity_type} metadata "{metadata_name}" in config',
                    subheader=(
                        f'The required metadata "{metadata_name}" is not a valid {entity_type} metadata.'
                        f" Please consider removing it from chalk.yml"
                    ),
                )
            if not value:
                res[severity].append(metadata_name)
    return res


def _get_missing_metadata_features_log(
    missing_metadata_features: List[MissingMetadataFeature], severity: GraphLogSeverity
) -> Union[Tuple[str, str], None]:
    if not missing_metadata_features:
        return None

    missing_messages = []
    feature_str = "feature"
    missing_metadata_header_str = "missing metadata"
    max_name_len = max([len(get_feature_type_name(w.feature)) for w in missing_metadata_features])
    feature_column_width = max(max_name_len, len(feature_str)) + 1
    get_padding = lambda s: feature_column_width - len(s)

    first_feature = missing_metadata_features[0].feature
    header = f'"{to_camel_case(get_feature_type_namespace(first_feature))}" features have missing metadata'
    subheader = f"  Filepath: {missing_metadata_features[0].namespace_path}\n\n"
    subheader = ""

    for wrapper in missing_metadata_features:
        missing_metadata = wrapper.missing_metadata.get(severity)
        if not missing_metadata:
            continue
        padding_1 = get_padding(get_feature_type_name(wrapper.feature))
        missing_metadata_str = ", ".join(missing_metadata)
        missing_messages.append(
            f"      {get_feature_type_name(wrapper.feature)}{' ' * padding_1}: {missing_metadata_str}"
        )

    if not missing_messages:
        return None

    padding_2 = get_padding(feature_str)
    subheader += f"      {'-' * len(feature_str)}{' ' * padding_2}  {'-' * len(missing_metadata_header_str)}\n"
    subheader += f"      {feature_str}{' ' * padding_2}  {missing_metadata_header_str}\n"
    subheader += f"      {'-' * len(feature_str)}{' ' * padding_2}  {'-' * len(missing_metadata_header_str)}\n"
    subheader += "\n".join(missing_messages)

    return header, subheader


def _get_missing_metadata_resolver_log(
    missing_metadata_resolver: MissingMetadataResolver, severity: GraphLogSeverity
) -> Union[Tuple[str, str], None]:
    missing_metadata = missing_metadata_resolver.missing_metadata.get(severity)
    if missing_metadata is None:
        return
    missing_metadata_str = ", ".join(missing_metadata)
    resolver_str = "resolver"
    missing_metadata_header_str = "missing metadata"
    resolver = missing_metadata_resolver.resolver
    max_name_len = len(resolver.fqn)
    feature_column_width = max(max_name_len, len(resolver_str)) + 1
    get_padding = lambda s: feature_column_width - len(s)
    padding_1 = get_padding(resolver.fqn)

    header = f'Resolver "{resolver.fqn}" has missing metadata'
    subheader = f"  Filepath: {missing_metadata_resolver.resolver.function.file_name}\n\n"
    message = f"      {resolver.fqn}{' ' * padding_1}: {missing_metadata_str}"

    padding_2 = get_padding(resolver_str)
    subheader += f"      {'-' * len(resolver_str)}{' ' * padding_2}  {'-' * len(missing_metadata_header_str)}\n"
    subheader += f"      {resolver_str}{' ' * padding_2}  {missing_metadata_header_str}\n"
    subheader += f"      {'-' * len(resolver_str)}{' ' * padding_2}  {'-' * len(missing_metadata_header_str)}\n"
    subheader += message

    return header, subheader


def _build_feature_log_for_each_severity(missing_metadata_features: List[MissingMetadataFeature], builder: _LogBuilder):
    if not missing_metadata_features:
        return

    for severity in GraphLogSeverity:
        log_or_none = _get_missing_metadata_features_log(
            missing_metadata_features=missing_metadata_features, severity=GraphLogSeverity(severity)
        )
        if log_or_none is None:
            continue
        header, subheader = log_or_none
        builder.add_log(header=header, subheader=subheader, severity=GraphLogSeverity(severity))


def _build_resolver_log_for_each_severity(
    missing_metadata_resolvers: List[MissingMetadataResolver], builder: _LogBuilder
):
    if not missing_metadata_resolvers:
        return

    for severity in GraphLogSeverity:
        for resolver in missing_metadata_resolvers:
            log_or_none = _get_missing_metadata_resolver_log(
                missing_metadata_resolver=resolver, severity=GraphLogSeverity(severity)
            )
            if log_or_none is None:
                continue
            header, subheader = log_or_none
            builder.add_log(header=header, subheader=subheader, severity=GraphLogSeverity(severity))


def _validate_feature_metadata(
    namespace_features: Collection[graph_pb.FeatureType],
    namespace_path: str,
    settings: ValidationSettings,
    builder: _LogBuilder,
) -> List[MissingMetadataFeature]:
    wrapped_features = []
    for nf in namespace_features:
        missing_metadata = _get_missing_metadata(
            entity=nf,
            attribute_map=_METADATA_STRING_TO_FEATURE_ATTRIBUTE_MAP,
            entity_type="feature",
            settings=settings.feature.metadata,
            builder=builder,
        )
        if not missing_metadata:
            continue
        wrapped_features.append(
            MissingMetadataFeature(feature=nf, missing_metadata=missing_metadata, namespace_path=namespace_path)
        )

    return wrapped_features


def _validate_metadata(
    feature_sets: List[graph_pb.FeatureSet],
    resolvers: List[graph_pb.Resolver],
    builder: _LogBuilder,
    settings: ValidationSettings,
):
    _validate_metadata_config(builder, settings)

    if settings.HasField("feature") and settings.feature.metadata:
        for fs in feature_sets:
            wf = _validate_feature_metadata(
                namespace_features=fs.features, namespace_path=fs.class_path, settings=settings, builder=builder
            )
            _build_feature_log_for_each_severity(missing_metadata_features=wf, builder=builder)
    if settings.resolver and settings.resolver.metadata:
        missing_resolver_metadatas: List[MissingMetadataResolver] = []
        for r in resolvers:
            metadata = _get_missing_metadata(
                r,
                _METADATA_STRING_TO_RESOLVER_ATTRIBUTE_MAP,
                "resolver",
                settings=settings.resolver.metadata,
                builder=builder,
            )
            missing_resolver_metadatas.append(MissingMetadataResolver(resolver=r, missing_metadata=metadata))
        _build_resolver_log_for_each_severity(missing_resolver_metadatas, builder=builder)


def _validate_metadata_config(builder: _LogBuilder, settings: ValidationSettings):
    if settings.HasField("feature"):
        for missing_metadata in settings.feature.metadata:
            metadata_name = missing_metadata.name
            severity = missing_metadata.missing
            try:
                GraphLogSeverity(severity.upper())
            except ValueError:
                builder.add(ValidationLogs.invalid_severity(severity, metadata_name))
            if metadata_name not in _METADATA_STRING_TO_FEATURE_ATTRIBUTE_MAP:
                builder.add(ValidationLogs.invalid_metadata_name(metadata_name, "feature"))
    if settings.HasField("resolver"):
        for missing_metadata in settings.resolver.metadata:
            metadata_name = missing_metadata.name
            severity = missing_metadata.missing
            try:
                GraphLogSeverity(severity.upper())
            except ValueError:
                builder.add(ValidationLogs.invalid_severity(severity, metadata_name))
            if metadata_name not in _METADATA_STRING_TO_RESOLVER_ATTRIBUTE_MAP:
                builder.add(ValidationLogs.invalid_metadata_name(metadata_name, "resolver"))


def _validate_namespace_primary_key(
    namespace: str,
    features: List[graph_pb.FeatureType],
    builder: _LogBuilder,
    singleton_namespaces: set[str],
):
    if namespace in singleton_namespaces:
        return

    primary_features = list(f for f in features if f.HasField("scalar") and f.scalar.is_primary)

    if len(primary_features) == 0:
        builder.add(ValidationLogs.no_pkey(namespace))
    elif len(primary_features) > 1:
        builder.add(ValidationLogs.multiple_pkeys(namespace, [get_feature_type_name(f) for f in primary_features]))


def validate_artifacts(graph: graph_pb.Graph, config: Optional[ProjectSettings]) -> List[ValidationLog]:
    """Beware: errors here will only be acted upon `chalk apply --branch`.
    Regular deployments will have their graph errors acted upon by the server.
    """
    new_graph = copy.deepcopy(graph)
    for fs in new_graph.feature_sets:
        all_features = list(fs.features)
        fs.ClearField("features")
        fs.features.extend([f for f in all_features if not get_feature_type_is_autogenerated(f)])
    graph = new_graph

    builder = _LogBuilder()
    singleton_namespaces = {c.name for c in graph.feature_sets if c.is_singleton}
    features_flattened = [f for fs in graph.feature_sets for f in fs.features]
    fqn_to_feature = {get_feature_type_fqn(f): f for f in features_flattened}

    # Validate the features
    _validate_feature_names_unique(features_flattened, builder)
    if config is not None and config.HasField("validation"):
        _validate_metadata(list(graph.feature_sets), list(graph.resolvers), builder, config.validation)

    garbage_feature_builder = FeatureClassErrorBuilder(uri="garbage.py", namespace="garbage", node=None)
    for fs in graph.feature_sets:
        _validate_namespace_primary_key(
            namespace=fs.name,
            features=list(fs.features),
            builder=builder,
            singleton_namespaces=singleton_namespaces,
        )
        lsp_builder = (
            FeatureSetBase.registry[fs.name].__chalk_error_builder__
            if fs.name in FeatureSetBase.registry and LSPErrorBuilder.lsp
            else garbage_feature_builder
        )
        for f in fs.features:
            _validate_primary_key(f, builder, lsp_builder)
            _validate_etl_offline_to_online(fs, f, builder)
            _validate_feature_names(f, builder, lsp_builder)

        # Validate joins
        if (fs_class := FeatureSetBase.registry.get(fs.name)) is not None:
            for f_field in fs_class.features:
                try:
                    f_field.join  # (this has the side effect of validating the join)
                except Exception as e:
                    if not LSPErrorBuilder.promote_exception(e):
                        raise

    # Validate the resolvers
    _validate_resolver_names_unique(list(graph.resolvers), builder)
    garbage_resolver_builder = ResolverErrorBuilder(fn=None)
    for resolver in graph.resolvers:
        try:
            lsp_builder = garbage_resolver_builder
            if LSPErrorBuilder.lsp:
                maybe_resolver = RESOLVER_REGISTRY.get_resolver(resolver.fqn)
                if maybe_resolver is not None:
                    if maybe_resolver.is_sql_file_resolver:
                        continue
                    lsp_builder = maybe_resolver.lsp_builder
            _validate_resolver_input(
                singleton_namespaces=singleton_namespaces,
                resolver=resolver,
                builder=builder,
                lsp_builder=lsp_builder,
            )
            _validate_resolver_output(resolver, builder, lsp_builder)
            _validate_no_feature_times_as_input(
                fqn_to_feature=fqn_to_feature, resolver=resolver, builder=builder, lsp_builder=lsp_builder
            )
        except Exception:
            err = traceback.format_exc()
            builder.add_error(
                header=f'Failed to validate resolver "{resolver.fqn}"',
                subheader=f"Please check the resolver for syntax errors.\nRaw error: {err}",
            )

    return builder.get_logs()
