import dataclasses
import datetime
import logging
import time
from collections import defaultdict
from typing import (
    TYPE_CHECKING,
    AbstractSet,
    Any,
    Dict,
    FrozenSet,
    Iterable,
    List,
    Mapping,
    Optional,
    Sequence,
    Set,
    Tuple,
    cast,
)

import pendulum

import dagster._check as check
from dagster._core.asset_graph_view.asset_graph_view import AssetGraphView, TemporalContext
from dagster._core.definitions.auto_materialize_policy import AutoMaterializePolicy
from dagster._core.definitions.data_time import CachingDataTimeResolver
from dagster._core.definitions.data_version import CachingStaleStatusResolver
from dagster._core.definitions.declarative_scheduling.scheduling_context import (
    SchedulingContext,
)
from dagster._core.definitions.declarative_scheduling.scheduling_evaluation_info import (
    SchedulingEvaluationInfo,
)
from dagster._core.definitions.events import AssetKey, AssetKeyPartitionKey
from dagster._core.definitions.run_request import RunRequest
from dagster._core.definitions.time_window_partitions import (
    get_time_partitions_def,
)
from dagster._core.instance import DynamicPartitionsStore

from ... import PartitionKeyRange
from ..storage.tags import ASSET_PARTITION_RANGE_END_TAG, ASSET_PARTITION_RANGE_START_TAG
from .asset_daemon_cursor import AssetDaemonCursor
from .auto_materialize_rule import AutoMaterializeRule
from .backfill_policy import BackfillPolicy, BackfillPolicyType
from .base_asset_graph import BaseAssetGraph
from .declarative_scheduling.legacy.legacy_context import (
    LegacyRuleEvaluationContext,
)
from .declarative_scheduling.serialized_objects import (
    AssetConditionEvaluation,
    AssetConditionEvaluationState,
)
from .freshness_based_auto_materialize import get_expected_data_time_for_asset_key
from .partition import PartitionsDefinition, ScheduleType

if TYPE_CHECKING:
    from dagster._core.instance import DagsterInstance
    from dagster._utils.caching_instance_queryer import CachingInstanceQueryer  # expensive import


def get_implicit_auto_materialize_policy(
    asset_key: AssetKey, asset_graph: BaseAssetGraph
) -> Optional[AutoMaterializePolicy]:
    """For backcompat with pre-auto materialize policy graphs, assume a default scope of 1 day."""
    auto_materialize_policy = asset_graph.get(asset_key).auto_materialize_policy
    if auto_materialize_policy is None:
        time_partitions_def = get_time_partitions_def(asset_graph.get(asset_key).partitions_def)
        if time_partitions_def is None:
            max_materializations_per_minute = None
        elif time_partitions_def.schedule_type == ScheduleType.HOURLY:
            max_materializations_per_minute = 24
        else:
            max_materializations_per_minute = 1
        rules = {
            AutoMaterializeRule.materialize_on_missing(),
            AutoMaterializeRule.materialize_on_required_for_freshness(),
            AutoMaterializeRule.skip_on_parent_outdated(),
            AutoMaterializeRule.skip_on_parent_missing(),
            AutoMaterializeRule.skip_on_required_but_nonexistent_parents(),
            AutoMaterializeRule.skip_on_backfill_in_progress(),
        }
        if not bool(asset_graph.get_downstream_freshness_policies(asset_key=asset_key)):
            rules.add(AutoMaterializeRule.materialize_on_parent_updated())
        return AutoMaterializePolicy(
            rules=rules,
            max_materializations_per_minute=max_materializations_per_minute,
        )
    return auto_materialize_policy


class AssetDaemonContext:
    def __init__(
        self,
        evaluation_id: int,
        instance: "DagsterInstance",
        asset_graph: BaseAssetGraph,
        cursor: AssetDaemonCursor,
        materialize_run_tags: Optional[Mapping[str, str]],
        observe_run_tags: Optional[Mapping[str, str]],
        auto_observe_asset_keys: Optional[AbstractSet[AssetKey]],
        auto_materialize_asset_keys: Optional[AbstractSet[AssetKey]],
        respect_materialization_data_versions: bool,
        logger: logging.Logger,
        evaluation_time: Optional[datetime.datetime] = None,
    ):
        from dagster._utils.caching_instance_queryer import CachingInstanceQueryer

        self._evaluation_id = evaluation_id
        self._instance_queryer = CachingInstanceQueryer(
            instance, asset_graph, evaluation_time=evaluation_time, logger=logger
        )
        self._data_time_resolver = CachingDataTimeResolver(self.instance_queryer)

        stale_resolver = CachingStaleStatusResolver(
            instance=instance, asset_graph=asset_graph, instance_queryer=self.instance_queryer
        )
        self._asset_graph_view = AssetGraphView(
            temporal_context=TemporalContext(
                effective_dt=self.instance_queryer.evaluation_time,
                # TODO: we should eventually pass in an explicit last_event_id
                last_event_id=None,
            ),
            stale_resolver=stale_resolver,
        )
        self._data_time_resolver = CachingDataTimeResolver(self.instance_queryer)
        self._cursor = cursor
        self._auto_materialize_asset_keys = auto_materialize_asset_keys or set()
        self._materialize_run_tags = materialize_run_tags
        self._observe_run_tags = observe_run_tags
        self._auto_observe_asset_keys = auto_observe_asset_keys or set()
        self._respect_materialization_data_versions = respect_materialization_data_versions
        self._logger = logger

        # cache data before the tick starts
        self.prefetch()

    @property
    def logger(self) -> logging.Logger:
        return self._logger

    @property
    def instance_queryer(self) -> "CachingInstanceQueryer":
        return self._instance_queryer

    @property
    def data_time_resolver(self) -> CachingDataTimeResolver:
        return self._data_time_resolver

    @property
    def asset_graph_view(self) -> AssetGraphView:
        return self._asset_graph_view

    @property
    def cursor(self) -> AssetDaemonCursor:
        return self._cursor

    @property
    def asset_graph(self) -> BaseAssetGraph:
        return self.instance_queryer.asset_graph

    @property
    def auto_materialize_asset_keys(self) -> AbstractSet[AssetKey]:
        return self._auto_materialize_asset_keys

    @property
    def auto_materialize_asset_keys_and_parents(self) -> AbstractSet[AssetKey]:
        return {
            parent
            for asset_key in self.auto_materialize_asset_keys
            for parent in self.asset_graph.get(asset_key).parent_keys
        } | self.auto_materialize_asset_keys

    @property
    def asset_records_to_prefetch(self) -> Sequence[AssetKey]:
        return [
            key for key in self.auto_materialize_asset_keys_and_parents if self.asset_graph.has(key)
        ]

    @property
    def respect_materialization_data_versions(self) -> bool:
        return self._respect_materialization_data_versions

    @property
    def auto_materialize_run_tags(self) -> Mapping[str, str]:
        return self._materialize_run_tags or {}

    def prefetch(self) -> None:
        """Pre-populate the cached values here to avoid situations in which the new latest_storage_id
        value is calculated using information that comes in after the set of asset partitions with
        new parent materializations is calculated, as this can result in materializations being
        ignored if they happen between the two calculations.
        """
        self._logger.info(
            f"Prefetching asset records for {len(self.asset_records_to_prefetch)} records."
        )
        self.instance_queryer.prefetch_asset_records(self.asset_records_to_prefetch)
        self._logger.info("Done prefetching asset records.")

    def evaluate_asset(
        self,
        asset_key: AssetKey,
        evaluation_state_by_key: Mapping[AssetKey, AssetConditionEvaluationState],
        expected_data_time_mapping: Mapping[AssetKey, Optional[datetime.datetime]],
        current_evaluation_info_by_key: Mapping[AssetKey, SchedulingEvaluationInfo],
    ) -> Tuple[AssetConditionEvaluationState, Optional[datetime.datetime]]:
        """Evaluates the auto materialize policy of a given asset key.

        Params:
            - asset_key: The asset key to evaluate.
            - will_materialize_mapping: A mapping of AssetKey to the set of AssetKeyPartitionKeys
                that will be materialized this tick. As this function is called in topological order,
                this mapping will contain the expected materializations of all upstream assets.
            - expected_data_time_mapping: A mapping of AssetKey to the expected data time of the
                asset after this tick. As this function is called in topological order, this mapping
                will contain the expected data times of all upstream assets.

        """
        # convert the legacy AutoMaterializePolicy to an Evaluator
        asset_condition = check.not_none(
            self.asset_graph.get(asset_key).auto_materialize_policy
        ).to_scheduling_condition()

        previous_evaluation_state = self.cursor.get_previous_evaluation_state(asset_key)

        legacy_context = LegacyRuleEvaluationContext.create(
            asset_key=asset_key,
            previous_evaluation_state=previous_evaluation_state,
            condition=asset_condition,
            instance_queryer=self.instance_queryer,
            data_time_resolver=self.data_time_resolver,
            daemon_context=self,
            evaluation_state_by_key=evaluation_state_by_key,
            expected_data_time_mapping=expected_data_time_mapping,
        )

        context = SchedulingContext.create(
            asset_key=asset_key,
            asset_graph_view=self.asset_graph_view,
            logger=self.logger,
            current_tick_evaluation_info_by_key=current_evaluation_info_by_key,
            previous_evaluation_info=SchedulingEvaluationInfo.from_asset_condition_evaluation_state(
                self.asset_graph_view, previous_evaluation_state
            )
            if previous_evaluation_state
            else None,
            legacy_context=legacy_context,
        )

        result = asset_condition.evaluate(context)

        expected_data_time = get_expected_data_time_for_asset_key(
            legacy_context, will_materialize=result.true_subset.size > 0
        )
        return AssetConditionEvaluationState.create(context, result), expected_data_time

    def get_asset_condition_evaluations(
        self,
    ) -> Tuple[Sequence[AssetConditionEvaluationState], AbstractSet[AssetKeyPartitionKey]]:
        """Returns a mapping from asset key to the AutoMaterializeAssetEvaluation for that key, a
        sequence of new per-asset cursors, and the set of all asset partitions that should be
        materialized or discarded this tick.
        """
        evaluation_state_by_key: Dict[AssetKey, AssetConditionEvaluationState] = {}
        current_evaluation_info_by_key: Dict[AssetKey, SchedulingEvaluationInfo] = {}
        expected_data_time_mapping: Dict[AssetKey, Optional[datetime.datetime]] = defaultdict()
        to_request: Set[AssetKeyPartitionKey] = set()

        num_checked_assets = 0
        num_auto_materialize_asset_keys = len(self.auto_materialize_asset_keys)

        for asset_key in self.asset_graph.toposorted_asset_keys:
            # an asset may have already been visited if it was part of a non-subsettable multi-asset
            if asset_key not in self.auto_materialize_asset_keys:
                continue

            num_checked_assets = num_checked_assets + 1
            start_time = time.time()
            self._logger.debug(
                "Evaluating asset"
                f" {asset_key.to_user_string()} ({num_checked_assets}/{num_auto_materialize_asset_keys})"
            )

            try:
                (evaluation_state, expected_data_time) = self.evaluate_asset(
                    asset_key,
                    evaluation_state_by_key,
                    expected_data_time_mapping,
                    current_evaluation_info_by_key,
                )
            except Exception as e:
                raise Exception(
                    f"Error while evaluating conditions for asset {asset_key.to_user_string()}"
                ) from e

            num_requested = evaluation_state.true_subset.size
            log_fn = self._logger.info if num_requested > 0 else self._logger.debug

            to_request_asset_partitions = evaluation_state.true_subset.asset_partitions
            to_request_str = ",".join(
                [(ap.partition_key or "No partition") for ap in to_request_asset_partitions]
            )
            to_request |= to_request_asset_partitions

            log_fn(
                f"Asset {asset_key.to_user_string()} evaluation result: {num_requested}"
                f" requested ({to_request_str}) ({format(time.time()-start_time, '.3f')} seconds)"
            )

            evaluation_state_by_key[asset_key] = evaluation_state
            current_evaluation_info_by_key[asset_key] = (
                SchedulingEvaluationInfo.from_asset_condition_evaluation_state(
                    self.asset_graph_view, evaluation_state
                )
            )
            expected_data_time_mapping[asset_key] = expected_data_time

            # if we need to materialize any partitions of a non-subsettable multi-asset, we need to
            # materialize all of them
            execution_set_keys = self.asset_graph.get(asset_key).execution_set_asset_keys
            if len(execution_set_keys) > 1 and num_requested > 0:
                for neighbor_key in execution_set_keys:
                    expected_data_time_mapping[neighbor_key] = expected_data_time

                    # make sure that the true_subset of the neighbor is accurate -- when it was
                    # evaluated it may have had a different requested AssetSubset. however, because
                    # all these neighbors must be executed as a unit, we need to union together
                    # the subset of all required neighbors
                    if neighbor_key in evaluation_state_by_key:
                        neighbor_evaluation_state = evaluation_state_by_key[neighbor_key]
                        evaluation_state_by_key[neighbor_key] = dataclasses.replace(
                            neighbor_evaluation_state,
                            previous_evaluation=neighbor_evaluation_state.previous_evaluation.copy(
                                update={
                                    "true_subset": neighbor_evaluation_state.true_subset.copy(
                                        update={"asset_key": neighbor_key}
                                    )
                                }
                            ),
                        )
                    to_request |= {
                        ap._replace(asset_key=neighbor_key)
                        for ap in evaluation_state.true_subset.asset_partitions
                    }

        return (list(evaluation_state_by_key.values()), to_request)

    def evaluate(
        self,
    ) -> Tuple[Sequence[RunRequest], AssetDaemonCursor, Sequence[AssetConditionEvaluation]]:
        observe_request_timestamp = pendulum.now().timestamp()
        auto_observe_run_requests = (
            get_auto_observe_run_requests(
                asset_graph=self.asset_graph,
                last_observe_request_timestamp_by_asset_key=self.cursor.last_observe_request_timestamp_by_asset_key,
                current_timestamp=observe_request_timestamp,
                run_tags=self._observe_run_tags,
                auto_observe_asset_keys=self._auto_observe_asset_keys,
            )
            if self._auto_observe_asset_keys
            else []
        )

        evaluation_state, to_request = self.get_asset_condition_evaluations()

        run_requests = [
            *build_run_requests(
                asset_partitions=to_request,
                asset_graph=self.asset_graph,
                run_tags=self.auto_materialize_run_tags,
            ),
            *auto_observe_run_requests,
        ]

        return (
            run_requests,
            self.cursor.with_updates(
                evaluation_id=self._evaluation_id,
                evaluation_state=evaluation_state,
                newly_observe_requested_asset_keys=[
                    asset_key
                    for run_request in auto_observe_run_requests
                    for asset_key in cast(Sequence[AssetKey], run_request.asset_selection)
                ],
                evaluation_timestamp=self.instance_queryer.evaluation_time.timestamp(),
            ),
            # only record evaluation results where something changed
            [
                es.previous_evaluation
                for es in evaluation_state
                if not es.previous_evaluation.equivalent_to_stored_evaluation(
                    self.cursor.get_previous_evaluation(es.asset_key)
                )
            ],
        )


def build_run_requests(
    asset_partitions: Iterable[AssetKeyPartitionKey],
    asset_graph: BaseAssetGraph,
    run_tags: Optional[Mapping[str, str]],
) -> Sequence[RunRequest]:
    assets_to_reconcile_by_partitions_def_partition_key: Mapping[
        Tuple[Optional[PartitionsDefinition], Optional[str]], Set[AssetKey]
    ] = defaultdict(set)

    for asset_partition in asset_partitions:
        assets_to_reconcile_by_partitions_def_partition_key[
            asset_graph.get(asset_partition.asset_key).partitions_def, asset_partition.partition_key
        ].add(asset_partition.asset_key)

    run_requests = []

    for (
        partitions_def,
        partition_key,
    ), asset_keys in assets_to_reconcile_by_partitions_def_partition_key.items():
        tags = {**(run_tags or {})}
        if partition_key is not None:
            if partitions_def is None:
                check.failed("Partition key provided for unpartitioned asset")
            tags.update({**partitions_def.get_tags_for_partition_key(partition_key)})

        for asset_keys_in_repo in asset_graph.split_asset_keys_by_repository(asset_keys):
            run_requests.append(
                # Do not call run_request.with_resolved_tags_and_config as the partition key is
                # valid and there is no config.
                # Calling with_resolved_tags_and_config is costly in asset reconciliation as it
                # checks for valid partition keys.
                RunRequest(
                    asset_selection=list(asset_keys_in_repo),
                    partition_key=partition_key,
                    tags=tags,
                )
            )

    # We don't make public guarantees about sort order, but make an effort to provide a consistent
    # ordering that puts earlier time partitions before later time partitions. Note that, with dates
    # formatted like 12/7/2023, the run requests won't end up in time order. Sorting by converting
    # to time windows seemed more risky from a perf perspective, so we didn't include it here, but
    # it could make sense to actually benchmark that in the future.
    return sorted(run_requests, key=lambda x: x.partition_key if x.partition_key else "")


def build_run_requests_with_backfill_policies(
    asset_partitions: Iterable[AssetKeyPartitionKey],
    asset_graph: BaseAssetGraph,
    run_tags: Optional[Mapping[str, str]],
    dynamic_partitions_store: DynamicPartitionsStore,
) -> Sequence[RunRequest]:
    """If all assets have backfill policies, we should respect them and materialize them according
    to their backfill policies.
    """
    run_requests = []

    asset_partition_keys: Mapping[AssetKey, Set[str]] = {
        asset_key_partition.asset_key: set() for asset_key_partition in asset_partitions
    }
    for asset_partition in asset_partitions:
        if asset_partition.partition_key:
            asset_partition_keys[asset_partition.asset_key].add(asset_partition.partition_key)

    assets_to_reconcile_by_partitions_def_partition_keys: Mapping[
        Tuple[Optional[PartitionsDefinition], Optional[FrozenSet[str]]], Set[AssetKey]
    ] = defaultdict(set)

    # here we are grouping assets by their partitions def and partition keys selected.
    for asset_key, partition_keys in asset_partition_keys.items():
        assets_to_reconcile_by_partitions_def_partition_keys[
            asset_graph.get(asset_key).partitions_def,
            frozenset(partition_keys) if partition_keys else None,
        ].add(asset_key)

    for (
        partitions_def,
        partition_keys,
    ), asset_keys in assets_to_reconcile_by_partitions_def_partition_keys.items():
        tags = {**(run_tags or {})}
        if partitions_def is None and partition_keys is not None:
            check.failed("Partition key provided for unpartitioned asset")
        if partitions_def is not None and partition_keys is None:
            check.failed("Partition key missing for partitioned asset")
        if partitions_def is None and partition_keys is None:
            # non partitioned assets will be backfilled in a single run
            run_requests.append(RunRequest(asset_selection=list(asset_keys), tags=tags))
        else:
            backfill_policies = {
                check.not_none(asset_graph.get(asset_key).backfill_policy)
                for asset_key in asset_keys
            }
            if len(backfill_policies) == 1:
                # if all backfill policies are the same, we can backfill them together
                backfill_policy = backfill_policies.pop()
                run_requests.extend(
                    _build_run_requests_with_backfill_policy(
                        list(asset_keys),
                        check.not_none(backfill_policy),
                        check.not_none(partition_keys),
                        check.not_none(partitions_def),
                        tags,
                        dynamic_partitions_store=dynamic_partitions_store,
                    )
                )
            else:
                # if backfill policies are different, we need to backfill them separately
                for asset_key in asset_keys:
                    backfill_policy = asset_graph.get(asset_key).backfill_policy
                    run_requests.extend(
                        _build_run_requests_with_backfill_policy(
                            [asset_key],
                            check.not_none(backfill_policy),
                            check.not_none(partition_keys),
                            check.not_none(partitions_def),
                            tags,
                            dynamic_partitions_store=dynamic_partitions_store,
                        )
                    )
    return run_requests


def _build_run_requests_with_backfill_policy(
    asset_keys: Sequence[AssetKey],
    backfill_policy: BackfillPolicy,
    partition_keys: FrozenSet[str],
    partitions_def: PartitionsDefinition,
    tags: Dict[str, Any],
    dynamic_partitions_store: DynamicPartitionsStore,
) -> Sequence[RunRequest]:
    run_requests = []
    partition_subset = partitions_def.subset_with_partition_keys(partition_keys)
    partition_key_ranges = partition_subset.get_partition_key_ranges(
        partitions_def, dynamic_partitions_store=dynamic_partitions_store
    )
    for partition_key_range in partition_key_ranges:
        # We might resolve more than one partition key range for the given partition keys.
        # We can only apply chunking on individual partition key ranges.
        if backfill_policy.policy_type == BackfillPolicyType.SINGLE_RUN:
            run_requests.append(
                _build_run_request_for_partition_key_range(
                    asset_keys=list(asset_keys),
                    partition_range_start=partition_key_range.start,
                    partition_range_end=partition_key_range.end,
                    run_tags=tags,
                )
            )
        else:
            run_requests.extend(
                _build_run_requests_for_partition_key_range(
                    asset_keys=list(asset_keys),
                    partitions_def=partitions_def,
                    partition_key_range=partition_key_range,
                    max_partitions_per_run=check.int_param(
                        backfill_policy.max_partitions_per_run, "max_partitions_per_run"
                    ),
                    run_tags=tags,
                    dynamic_partitions_store=dynamic_partitions_store,
                )
            )
    return run_requests


def _build_run_requests_for_partition_key_range(
    asset_keys: Sequence[AssetKey],
    partitions_def: PartitionsDefinition,
    partition_key_range: PartitionKeyRange,
    max_partitions_per_run: int,
    run_tags: Dict[str, str],
    dynamic_partitions_store: DynamicPartitionsStore,
) -> Sequence[RunRequest]:
    """Builds multiple run requests for the given partition key range. Each run request will have at most
    max_partitions_per_run partitions.
    """
    partition_keys = partitions_def.get_partition_keys_in_range(
        partition_key_range, dynamic_partitions_store=dynamic_partitions_store
    )
    partition_range_start_index = partition_keys.index(partition_key_range.start)
    partition_range_end_index = partition_keys.index(partition_key_range.end)

    partition_chunk_start_index = partition_range_start_index
    run_requests = []
    while partition_chunk_start_index <= partition_range_end_index:
        partition_chunk_end_index = partition_chunk_start_index + max_partitions_per_run - 1
        if partition_chunk_end_index > partition_range_end_index:
            partition_chunk_end_index = partition_range_end_index
        partition_chunk_start_key = partition_keys[partition_chunk_start_index]
        partition_chunk_end_key = partition_keys[partition_chunk_end_index]
        run_requests.append(
            _build_run_request_for_partition_key_range(
                asset_keys, partition_chunk_start_key, partition_chunk_end_key, run_tags
            )
        )
        partition_chunk_start_index = partition_chunk_end_index + 1
    return run_requests


def _build_run_request_for_partition_key_range(
    asset_keys: Sequence[AssetKey],
    partition_range_start: str,
    partition_range_end: str,
    run_tags: Dict[str, str],
) -> RunRequest:
    """Builds a single run request for the given asset key and partition key range."""
    tags = {
        **(run_tags or {}),
        ASSET_PARTITION_RANGE_START_TAG: partition_range_start,
        ASSET_PARTITION_RANGE_END_TAG: partition_range_end,
    }
    return RunRequest(asset_selection=asset_keys, tags=tags)


def get_auto_observe_run_requests(
    last_observe_request_timestamp_by_asset_key: Mapping[AssetKey, float],
    current_timestamp: float,
    asset_graph: BaseAssetGraph,
    run_tags: Optional[Mapping[str, str]],
    auto_observe_asset_keys: AbstractSet[AssetKey],
) -> Sequence[RunRequest]:
    assets_to_auto_observe: Set[AssetKey] = set()
    for asset_key in auto_observe_asset_keys:
        last_observe_request_timestamp = last_observe_request_timestamp_by_asset_key.get(asset_key)
        auto_observe_interval_minutes = asset_graph.get(asset_key).auto_observe_interval_minutes

        if auto_observe_interval_minutes and (
            last_observe_request_timestamp is None
            or (
                last_observe_request_timestamp + auto_observe_interval_minutes * 60
                < current_timestamp
            )
        ):
            assets_to_auto_observe.add(asset_key)

    # create groups of asset keys that share the same repository AND the same partitions definition
    partitions_def_and_asset_key_groups: List[Sequence[AssetKey]] = []
    for repository_asset_keys in asset_graph.split_asset_keys_by_repository(assets_to_auto_observe):
        asset_keys_by_partitions_def = defaultdict(list)
        for asset_key in repository_asset_keys:
            partitions_def = asset_graph.get(asset_key).partitions_def
            asset_keys_by_partitions_def[partitions_def].append(asset_key)
        partitions_def_and_asset_key_groups.extend(asset_keys_by_partitions_def.values())

    return [
        RunRequest(asset_selection=list(asset_keys), tags=run_tags)
        for asset_keys in partitions_def_and_asset_key_groups
        if len(asset_keys) > 0
    ]
