import json
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import (
    Any,
    Generator,
    Optional,
    Sequence,
    Set,
    TypeVar,
    Union,
    cast,
)

import snowflake.connector

from . import data_provider, lifecycle
from .blueprint_config import BlueprintConfig
from .builtins import SYSTEM_ROLES
from .client import (
    ALREADY_EXISTS_ERR,
    DOES_NOT_EXIST_ERR,
    INVALID_GRANT_ERR,
    execute,
    reset_cache,
)
from .data_provider import SessionContext
from .enums import (
    AccountEdition,
    BlueprintScope,
    GrantType,
    ResourceType,
    resource_type_is_grant,
)
from .error_formatting import (
    format_missing_container_error,
    format_missing_pointer_error,
    format_missing_resource_error,
)
from .exceptions import (
    DuplicateResourceException,
    MissingPrivilegeException,
    MissingResourceException,
    NonConformingPlanException,
    NotADAGException,
    OrphanResourceException,
)
from .identifiers import URN, parse_identifier, parse_URN, resource_label_for_type
from .privs import CREATE_PRIV_FOR_RESOURCE_TYPE, system_role_for_priv
from .resource_name import ResourceName
from .resource_tags import ResourceTags
from .resources import Database, Grant, RoleGrant, Schema
from .resources.database import public_schema_urn
from .resources.resource import (
    RESOURCE_SCOPES,
    NamedResource,
    Resource,
    ResourceContainer,
    ResourceLifecycleConfig,
    ResourcePointer,
    infer_role_type_from_name,
)
from .resources.role import Role
from .resources.tag import Tag, TaggableResource
from .scope import (
    AccountScope,
    DatabaseScope,
    OrganizationScope,
    SchemaScope,
    TableScope,
)

T = TypeVar("T")
ResourceRef = Union[tuple[ResourceType, str], str]


logger = logging.getLogger("snowcap")


def resource_urn_needs_params(urn: URN, manifest: "Manifest") -> bool:
    """
    Check if a specific URN needs parameter fields fetched.

    This checks if the resource at this URN has any parameter fields with non-None values.
    If it doesn't, we can skip the expensive SHOW PARAMETERS query.

    Args:
        urn: The URN to check
        manifest: The manifest to check

    Returns:
        True if this resource needs parameter data, False otherwise
    """
    resource_label = resource_label_for_type(urn.resource_type)
    param_fields = data_provider.PARAMETER_FIELDS.get(resource_label, set())
    if not param_fields:
        return True  # No optimization for this resource type

    # Check if this specific URN is in the manifest with param fields
    if urn not in manifest.urns:
        return False  # Not in manifest, skip params

    item = manifest[urn]
    if isinstance(item, ManifestResource):
        # Skip implicit resources (like PUBLIC schema created by database)
        if item.implicit:
            return False
        # Only need params if this resource has non-None param field values
        for field in param_fields:
            if field in item.data and item.data[field] is not None:
                return True
    return False


def resource_type_needs_params(resource_type: ResourceType, manifest: "Manifest") -> bool:
    """
    Check if any resource of this type in the manifest specifies parameter fields
    with non-None values.

    This is used to optimize fetch_remote_state by skipping expensive SHOW PARAMETERS
    queries when no resource of that type in the manifest needs the parameter data.

    Args:
        resource_type: The type of resource to check
        manifest: The manifest to check

    Returns:
        True if any resource of this type needs parameter data, False otherwise
    """
    resource_label = resource_label_for_type(resource_type)
    param_fields = data_provider.PARAMETER_FIELDS.get(resource_label, set())
    if not param_fields:
        return True  # No optimization for this resource type

    # For schemas, we use per-URN optimization via schema_urn_needs_params() instead
    # of type-level optimization. Return True here to avoid the type-level cache blocking
    # individual schema checks.
    if resource_type == ResourceType.SCHEMA:
        return True  # Delegate to per-URN check

    # Check all resources of this type in manifest
    for urn in manifest.urns:
        if urn.resource_type != resource_type:
            continue
        item = manifest[urn]
        if isinstance(item, ManifestResource):
            # Skip implicit resources (like PUBLIC schema created by database)
            if item.implicit:
                continue
            # Only consider fields that have non-None values
            # Fields with None values indicate the user didn't explicitly set them
            for field in param_fields:
                if field in item.data and item.data[field] is not None:
                    return True  # At least one resource specifies a non-None parameter field
    return False  # No resources of this type specify parameter fields with values


def databases_with_param_fields(manifest: "Manifest") -> set:
    """
    Return the set of database names that have param fields set in the manifest.
    Used to determine which PUBLIC schemas need param fetching (they inherit from database).
    """
    db_param_fields = data_provider.PARAMETER_FIELDS.get("database", set())
    databases = set()
    for urn in manifest.urns:
        if urn.resource_type != ResourceType.DATABASE:
            continue
        item = manifest[urn]
        if isinstance(item, ManifestResource):
            for field in db_param_fields:
                if field in item.data and item.data[field] is not None:
                    databases.add(str(urn.fqn.name).upper())
                    break
    return databases


def schema_urn_needs_params(urn: URN, manifest: "Manifest", db_with_params: set) -> bool:
    """
    Check if a specific schema URN needs parameter fields fetched.

    A schema needs params if:
    1. The schema is in the manifest with param fields set, OR
    2. The schema is PUBLIC and its parent database has param fields (inheritance)

    Args:
        urn: The schema URN to check
        manifest: The manifest to check against
        db_with_params: Set of database names that have param fields set

    Returns:
        True if this schema needs params fetched, False otherwise
    """
    schema_param_fields = data_provider.PARAMETER_FIELDS.get("schema", set())

    # Check if this schema is in manifest with param fields
    if urn in manifest.urns:
        item = manifest[urn]
        if isinstance(item, ManifestResource):
            for field in schema_param_fields:
                if field in item.data and item.data[field] is not None:
                    return True

    # Check if this is a PUBLIC schema whose database has param fields
    schema_name = str(urn.fqn.name).upper()
    if schema_name == "PUBLIC":
        db_name = str(urn.fqn.database).upper() if urn.fqn.database else None
        if db_name and db_name in db_with_params:
            return True

    return False


def manifest_has_future_grants(manifest: "Manifest") -> bool:
    """
    Check if the manifest contains any future grants.

    This is used to optimize list_grants by skipping expensive SHOW FUTURE GRANTS
    queries when the manifest doesn't define any future grants.
    """
    for urn in manifest.urns:
        if urn.resource_type != ResourceType.GRANT:
            continue
        item = manifest[urn]
        if isinstance(item, ManifestResource):
            if item.data.get("grant_type") == "FUTURE":
                return True
    return False


def manifest_future_grant_roles(manifest: "Manifest") -> set:
    """
    Return the set of role names that have future grants in the manifest.

    This is used to optimize SHOW FUTURE GRANTS by only querying roles
    that actually have future grants defined in the manifest.
    """
    roles = set()
    for urn in manifest.urns:
        if urn.resource_type != ResourceType.GRANT:
            continue
        item = manifest[urn]
        if isinstance(item, ManifestResource):
            if item.data.get("grant_type") == "FUTURE":
                # The "to" field contains the role name (FQN string)
                to = item.data.get("to", "")
                if to:
                    # Handle both formats: "role/SOME_ROLE" or just "SOME_ROLE"
                    if "/" in to:
                        role_name = to.split("/", 1)[1]
                    else:
                        role_name = to
                    roles.add(role_name.upper())
    return roles


@dataclass
class ResourceChange(ABC):
    urn: URN

    @abstractmethod
    def to_dict(self) -> dict[str, Any]:
        pass


ResourceOwner = ResourceName
ContainerDescriptor = tuple[URN, ResourceOwner]


@dataclass
class CreateResource(ResourceChange):
    resource_cls: type[Resource]
    container: Optional[ContainerDescriptor]
    after: dict[str, str]

    def to_dict(self) -> dict[str, Union[str, dict[str, str], None]]:
        container_dict = None
        if self.container is not None:
            container_urn, container_owner = self.container
            container_dict = {str(container_urn): str(container_owner)}
        return {
            "action": "CREATE",
            "urn": str(self.urn),
            "resource_cls": self.resource_cls.__name__,
            "container": container_dict,
            "after": self.after,
        }


@dataclass
class DropResource(ResourceChange):
    before: dict[str, str]

    def to_dict(self) -> dict[str, Union[str, dict[str, str]]]:
        return {
            "action": "DROP",
            "urn": str(self.urn),
            "before": self.before,
        }


@dataclass
class UpdateResource(ResourceChange):
    resource_cls: type[Resource]
    before: dict[str, str]
    after: dict[str, str]
    delta: dict[str, str]

    def to_dict(self) -> dict[str, Union[str, dict[str, str]]]:
        return {
            "action": "UPDATE",
            "urn": str(self.urn),
            "resource_cls": self.resource_cls.__name__,
            "before": self.before,
            "after": self.after,
            "delta": self.delta,
        }


@dataclass
class TransferOwnership(ResourceChange):
    resource_cls: type[Resource]
    from_owner: str
    to_owner: str

    def to_dict(self) -> dict[str, str]:
        return {
            "action": "TRANSFER",
            "urn": str(self.urn),
            "resource_cls": self.resource_cls.__name__,
            "from_owner": self.from_owner,
            "to_owner": self.to_owner,
        }


State = dict[URN, dict]
Plan = list[ResourceChange]


def plan_from_dict(plan_dict: dict) -> Plan:
    changes: list[ResourceChange] = []
    for change in plan_dict:
        action = change["action"]
        if action == "CREATE":
            container_descriptor: Optional[ContainerDescriptor] = None
            if change.get("container"):
                for urn, owner in change["container"].items():
                    container_descriptor = (parse_URN(urn), ResourceName(owner))
            changes.append(
                CreateResource(
                    urn=parse_URN(change["urn"]),
                    resource_cls=Resource.__classes__[change["resource_cls"]],
                    container=container_descriptor,
                    after=change["after"],
                )
            )
        elif action == "DROP":
            changes.append(
                DropResource(
                    urn=parse_URN(change["urn"]),
                    before=change["before"],
                )
            )
        elif action == "UPDATE":
            changes.append(
                UpdateResource(
                    urn=parse_URN(change["urn"]),
                    resource_cls=Resource.__classes__[change["resource_cls"]],
                    before=change["before"],
                    after=change["after"],
                    delta=change["delta"],
                )
            )
        elif action == "TRANSFER":
            changes.append(
                TransferOwnership(
                    urn=parse_URN(change["urn"]),
                    resource_cls=Resource.__classes__[change["resource_cls"]],
                    from_owner=change["from_owner"],
                    to_owner=change["to_owner"],
                )
            )
        else:
            raise Exception(f"Unsupported action {action}")
    return changes


@dataclass
class ManifestResource:
    urn: URN
    resource_cls: type[Resource]
    data: dict[str, Any]
    implicit: bool
    lifecycle: ResourceLifecycleConfig


class Manifest:
    def __init__(self, account_locator: str = ""):
        self._account_locator = account_locator
        self._resources: dict[URN, Union[ManifestResource, ResourcePointer]] = {}
        self._refs: list[tuple[URN, URN]] = []

    def __getitem__(self, key: URN):
        if isinstance(key, URN):
            return self._resources[key]
        else:
            raise Exception("Manifest keys must be URNs")

    def __contains__(self, key: URN):
        if isinstance(key, URN):
            return key in self._resources
        else:
            raise Exception("Manifest keys must be URNs")

    def add(self, resource: Resource, account_edition: AccountEdition):

        urn = URN.from_resource(
            account_locator=self._account_locator,
            resource=resource,
        )

        if urn in self._resources:
            if not isinstance(resource, ResourcePointer):
                logger.warning(f"Duplicate resource {urn} with conflicting data, discarding {resource}")
            return
        if isinstance(resource, ResourcePointer):
            self._resources[urn] = resource
        else:
            self._resources[urn] = ManifestResource(
                urn,
                resource.__class__,
                resource.to_dict(account_edition),
                resource.implicit,
                resource.lifecycle,
            )
        for ref in resource.refs:
            ref_urn = URN.from_resource(account_locator=self._account_locator, resource=ref)
            self._refs.append((urn, ref_urn))

    def get(self, key: URN, default=None):
        if isinstance(key, URN):
            return self._resources.get(key, default)
        else:
            raise Exception("Manifest keys must be URNs")

    def items(self):
        return self._resources.items()

    def __repr__(self):
        contents = ""
        for urn, resource in self._resources.items():
            contents += f"[{urn}] =>\n"
            contents += f"  {resource}\n"
        return f"Manifest({len(self._resources)} resources)\n{contents}"

    @property
    def urns(self) -> list[URN]:
        return list(self._resources.keys())

    @property
    def refs(self):
        return self._refs

    @property
    def resources(self):
        return list(self._resources.values())


def dump_plan(plan: Plan, format: str = "json"):
    if format == "json":
        return json.dumps([change.to_dict() for change in plan], indent=2)
    elif format == "text":
        return _dump_plan_text(plan)
    else:
        raise Exception(f"Unsupported format {format}")


def _render_value(value):
    """Render a value for display in plan output."""
    if isinstance(value, str):
        return f'"{value}"'
    return str(value)


def _render_table(rows: list[list[str]], headers: list[str]) -> str:
    """
    Render a table with box-drawing characters.

    Args:
        rows: List of rows, each row is a list of cell values
        headers: List of column headers

    Returns:
        Formatted table string with box drawing characters
    """
    if not rows:
        return ""

    # Calculate column widths
    num_cols = len(headers)
    col_widths = [len(h) for h in headers]
    for row in rows:
        for i, cell in enumerate(row):
            col_widths[i] = max(col_widths[i], len(str(cell)))

    # Build the table
    lines = []

    # Top border
    top_border = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐"
    lines.append(top_border)

    # Header row
    header_cells = [f" {headers[i]:<{col_widths[i]}} " for i in range(num_cols)]
    lines.append("│" + "│".join(header_cells) + "│")

    # Header separator
    header_sep = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤"
    lines.append(header_sep)

    # Data rows
    for row in rows:
        cells = [f" {str(row[i]):<{col_widths[i]}} " for i in range(num_cols)]
        lines.append("│" + "│".join(cells) + "│")

    # Bottom border
    bottom_border = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘"
    lines.append(bottom_border)

    return "\n".join(lines)


def _get_resource_type_order(resource_type: ResourceType) -> tuple[int, str]:
    """
    Return a sort key for resource types.
    Account-level resources come first, then database, then schema-level.
    """
    # Define ordering groups
    account_level = {
        ResourceType.ACCOUNT,
        ResourceType.ACCOUNT_PARAMETER,
        ResourceType.ROLE,
        ResourceType.USER,
        ResourceType.WAREHOUSE,
        ResourceType.RESOURCE_MONITOR,
        ResourceType.NETWORK_POLICY,
        ResourceType.SHARE,
        ResourceType.STORAGE_INTEGRATION,
        ResourceType.API_INTEGRATION,
        ResourceType.NOTIFICATION_INTEGRATION,
        ResourceType.SECURITY_INTEGRATION,
        ResourceType.EXTERNAL_ACCESS_INTEGRATION,
        ResourceType.EXTERNAL_VOLUME,
        ResourceType.COMPUTE_POOL,
        ResourceType.FAILOVER_GROUP,
        ResourceType.REPLICATION_GROUP,
        ResourceType.CATALOG_INTEGRATION,
        ResourceType.AUTHENTICATION_POLICY,
        ResourceType.PASSWORD_POLICY,
        ResourceType.PACKAGES_POLICY,
    }
    database_level = {
        ResourceType.DATABASE,
        ResourceType.DATABASE_ROLE,
        ResourceType.SCHEMA,
    }
    grant_types = {
        ResourceType.GRANT,
        ResourceType.ROLE_GRANT,
        ResourceType.DATABASE_ROLE_GRANT,
    }

    # Return sort key: (group_order, resource_type_name)
    if resource_type in account_level:
        return (0, str(resource_type))
    elif resource_type in database_level:
        return (1, str(resource_type))
    elif resource_type in grant_types:
        return (3, str(resource_type))  # Grants come last
    else:
        return (2, str(resource_type))  # Schema-level resources


def _format_grant_name(urn: URN, change: "ResourceChange") -> str:
    """
    Format a grant URN into a readable format.
    Example: USAGE on WAREHOUSE.REPORTING → ROLE.ANALYST
    Example (future grant): SELECT on FUTURE TABLES in DATABASE.MYDB → ROLE.ANALYST
    Example (role grant): ROLE.ANALYST → ROLE.SYSADMIN
    """
    # Get grant details from the change
    if isinstance(change, CreateResource):
        data = change.after
    elif isinstance(change, DropResource):
        data = change.before
    else:
        data = getattr(change, "after", {}) or getattr(change, "before", {})

    resource_type = urn.resource_type

    # Handle role grants (ROLE_GRANT, DATABASE_ROLE_GRANT)
    if resource_type == ResourceType.ROLE_GRANT:
        role = data.get("role", "")
        to_role = data.get("to_role", "")
        to_user = data.get("to_user", "")
        if role:
            if to_role:
                return f"ROLE.{role} → ROLE.{to_role}"
            elif to_user:
                return f"ROLE.{role} → USER.{to_user}"
        return str(urn.fqn.name)

    if resource_type == ResourceType.DATABASE_ROLE_GRANT:
        role = data.get("role", "")
        to_role = data.get("to_role", "")
        to_database_role = data.get("to_database_role", "")
        if role:
            if to_role:
                return f"DATABASE_ROLE.{role} → ROLE.{to_role}"
            elif to_database_role:
                return f"DATABASE_ROLE.{role} → DATABASE_ROLE.{to_database_role}"
        return str(urn.fqn.name)

    # Handle regular grants
    priv = data.get("priv", "")
    on_type = data.get("on_type", "")
    on = data.get("on", "")
    to_type = data.get("to_type", "")
    to = data.get("to", "")
    grant_type = data.get("grant_type", "")
    items_type = data.get("items_type", "")

    if priv and to:
        to_type_str = str(to_type).replace("ResourceType.", "").upper() if to_type else "ROLE"

        # Handle FUTURE and ALL grants
        grant_type_str = str(grant_type).replace("GrantType.", "").upper() if grant_type else "OBJECT"

        if grant_type_str == "FUTURE" and items_type:
            # Format: SELECT on FUTURE TABLES in DATABASE.MYDB → ROLE.X
            items_type_str = str(items_type).replace("ResourceType.", "").upper()
            # Pluralize the items type
            items_plural = items_type_str + "S" if not items_type_str.endswith("S") else items_type_str
            on_type_str = str(on_type).replace("ResourceType.", "").upper() if on_type else ""
            return f"{priv} on FUTURE {items_plural} in {on_type_str}.{on} → {to_type_str}.{to}"

        elif grant_type_str == "ALL" and items_type:
            # Format: SELECT on ALL TABLES in DATABASE.MYDB → ROLE.X
            items_type_str = str(items_type).replace("ResourceType.", "").upper()
            items_plural = items_type_str + "S" if not items_type_str.endswith("S") else items_type_str
            on_type_str = str(on_type).replace("ResourceType.", "").upper() if on_type else ""
            return f"{priv} on ALL {items_plural} in {on_type_str}.{on} → {to_type_str}.{to}"

        elif on_type:
            # Regular object grant: SELECT on TABLE.MYTABLE → ROLE.X
            on_type_str = str(on_type).replace("ResourceType.", "").upper()
            return f"{priv} on {on_type_str}.{on} → {to_type_str}.{to}"

    # Fallback to FQN name
    return str(urn.fqn.name)


def _format_resource_name(urn: URN, change: "ResourceChange") -> str:
    """
    Extract a clean resource name from a URN and change.
    For grants, returns a readable format.
    For other resources, returns the resource name with key properties.
    """
    resource_type = urn.resource_type

    # Handle grants specially
    if resource_type in (ResourceType.GRANT, ResourceType.ROLE_GRANT, ResourceType.DATABASE_ROLE_GRANT):
        return _format_grant_name(urn, change)

    # For other resources, use the FQN
    fqn = urn.fqn
    if fqn.database and fqn.schema:
        name = f"{fqn.database}.{fqn.schema}.{fqn.name}"
    elif fqn.database:
        name = f"{fqn.database}.{fqn.name}"
    else:
        name = str(fqn.name)

    # Include params for resources that use them (like tag_masking_policy_reference)
    if fqn.params:
        params_str = ", ".join(f"{k}={v}" for k, v in fqn.params.items())
        name += f" ({params_str})"

    return name


def _get_key_properties(change: "ResourceChange", resource_type: ResourceType) -> str:
    """Get key properties to display inline for CREATE actions."""
    if not isinstance(change, CreateResource):
        return ""

    data = change.after
    props = []

    # Skip owner for grants since the relationship is shown in the name
    is_grant = resource_type in (
        ResourceType.GRANT,
        ResourceType.ROLE_GRANT,
        ResourceType.DATABASE_ROLE_GRANT,
    )

    # Show owner if present (but not for grants)
    if not is_grant and "owner" in data and data["owner"]:
        props.append(f"owner: {data['owner']}")

    # Show size for warehouses
    if "warehouse_size" in data:
        props.append(f"size: {data['warehouse_size']}")

    if props:
        return f" ({', '.join(props)})"
    return ""


def _dump_plan_text(plan: Plan) -> str:
    """
    Generate improved text output for a plan.

    Groups changes by resource type with:
    - Section headers for each resource type
    - Compact single-line format for creates/drops
    - Before/After tables for updates
    """
    # Datacoves brand colors (blue/cyan)
    blue = "\033[94m"
    cyan = "\033[96m"
    green = "\033[92m"
    red = "\033[91m"
    yellow = "\033[93m"
    dim = "\033[2m"
    reset = "\033[0m"

    # Count changes by type
    create_count = len([c for c in plan if isinstance(c, CreateResource)])
    update_count = len([c for c in plan if isinstance(c, UpdateResource)])
    transfer_count = len([c for c in plan if isinstance(c, TransferOwnership)])
    drop_count = len([c for c in plan if isinstance(c, DropResource)])

    output = f"\n{cyan}»{reset} {blue}snowcap{reset}\n"
    output += f"{cyan}»{reset} Plan: {create_count} to create, {update_count} to update, {transfer_count} to transfer, {drop_count} to drop.\n"

    if not plan:
        return output + "\n"

    # Group changes by resource type
    changes_by_type: dict[ResourceType, list[ResourceChange]] = defaultdict(list)
    for change in plan:
        changes_by_type[change.urn.resource_type].append(change)

    # Sort resource types
    sorted_types = sorted(changes_by_type.keys(), key=_get_resource_type_order)

    for resource_type in sorted_types:
        changes = changes_by_type[resource_type]

        # Section header
        type_label = str(resource_type).upper().replace(" ", "_") + "S"
        header_line = f"━━━ {type_label} "
        header_line += "━" * (70 - len(header_line))
        output += f"\n{header_line}\n"

        # Track if we have ALL grants in this section
        has_all_grants = False

        for change in changes:
            name = _format_resource_name(change.urn, change)

            # Check for ALL grants
            if resource_type == ResourceType.GRANT and isinstance(change, CreateResource):
                grant_type = change.after.get("grant_type", "")
                grant_type_str = str(grant_type).replace("GrantType.", "").upper()
                if grant_type_str == "ALL":
                    has_all_grants = True

            if isinstance(change, CreateResource):
                props = _get_key_properties(change, resource_type)
                output += f"{green}+ CREATE:{reset} {name}{props}\n"

            elif isinstance(change, DropResource):
                output += f"{red}- DROP:{reset}   {name}\n"

            elif isinstance(change, UpdateResource):
                output += f"{yellow}~ UPDATE:{reset} {name}\n"
                # Build table for changed properties
                rows = []
                for key, new_value in change.delta.items():
                    if key.startswith("_"):
                        continue
                    before = change.before.get(key, "")
                    rows.append(
                        [
                            key,
                            str(before) if before is not None else "",
                            str(new_value) if new_value is not None else "",
                        ]
                    )

                if rows:
                    table = _render_table(rows, ["Property", "Before", "After"])
                    # Indent table
                    indented_table = "\n".join("  " + line for line in table.split("\n"))
                    output += indented_table + "\n"

            elif isinstance(change, TransferOwnership):
                output += f"{yellow}~ TRANSFER:{reset} {name}\n"
                # Build table for owner change
                rows = [
                    [
                        "owner",
                        str(change.from_owner) if change.from_owner else "",
                        str(change.to_owner) if change.to_owner else "",
                    ]
                ]
                table = _render_table(rows, ["Property", "Before", "After"])
                indented_table = "\n".join("  " + line for line in table.split("\n"))
                output += indented_table + "\n"

        # Add note about ALL grants if present
        if has_all_grants:
            output += f'\n{dim}Note: "ALL" grants always appear in the plan because Snowflake converts them{reset}\n'
            output += f"{dim}to individual object grants. They are idempotent and safe to apply.{reset}\n"

    output += "\n"
    return output


def print_plan(plan: Plan):
    print(dump_plan(plan, format="text"))


def print_diffs(diffs):
    for action, target, deltas in diffs:
        print(f"[{action}]", target)
        for delta in deltas:
            print("\t", delta)


def _split_by_scope(
    resources: list[Resource],
) -> tuple[list[Resource], list[Resource], list[Resource], list[Resource]]:
    org_scoped: list[Resource] = []
    acct_scoped: list[Resource] = []
    db_scoped: list[Resource] = []
    schema_scoped: list[Resource] = []

    seen = set()

    def route(resource: Resource):
        """The sorting hat"""

        if id(resource) not in seen:
            if isinstance(resource.scope, OrganizationScope):
                org_scoped.append(resource)
            elif isinstance(resource.scope, AccountScope):
                acct_scoped.append(resource)
            elif isinstance(resource.scope, DatabaseScope):
                db_scoped.append(resource)
            elif isinstance(resource.scope, SchemaScope):
                schema_scoped.append(resource)
            else:
                raise Exception(f"Unsupported resource type {type(resource)}")

        seen.add(id(resource))
        if isinstance(resource, ResourceContainer):
            for item in resource.items():
                route(item)

    for resource in resources:
        root = resource
        while getattr(root, "container", None) is not None:
            root = getattr(root, "container")
        route(root)
    return org_scoped, acct_scoped, db_scoped, schema_scoped


def _walk(resource: Resource) -> Generator[Resource, None, None]:
    yield resource
    if isinstance(resource, ResourceContainer):
        for item in resource.items():
            yield from _walk(item)


def _raise_if_plan_would_drop_session_user(session_ctx: SessionContext, plan: Plan):
    for change in plan:
        if change.urn.resource_type == ResourceType.USER and isinstance(change, DropResource):
            if ResourceName(session_ctx["user"]) == ResourceName(change.urn.fqn.name):
                raise Exception("Plan would drop the current session user, which is not allowed")


def _merge_pointers(resources: Sequence[Resource]) -> list[Resource]:
    """
    It is expected in yaml-defined blueprints that all resources are defined with static strings, instead
    of using object references.

    """

    namespace: dict[ResourceRef, Resource] = {}
    # Push pointers to the end
    resources = sorted(resources, key=lambda resource: isinstance(resource, ResourcePointer))

    def _merge(resource: ResourceContainer, pointer: ResourcePointer):
        if pointer.container is not None:
            # # The pointer has a container but the resource does not, merge fails
            # if getattr(resource, "container", None) is None:
            #     raise Exception(f"Cannot merge pointer {pointer} into resource {resource}")
            pointer.container.remove(pointer)

        # Migrate items from pointer to resource
        for item in pointer.items():
            pointer.remove(item)
            resource.add(item)

    for resource_or_pointer in resources:
        # Create a unique identifier for the resource
        resource_id: ResourceRef
        if isinstance(resource_or_pointer, NamedResource):
            resource_id = (
                resource_or_pointer.resource_type,
                str(resource_or_pointer.name),
            )
        else:
            resource_id = str(resource_or_pointer.urn)

        # If the resource is a pointer, attempt to merge it to an existing resource
        if isinstance(resource_or_pointer, ResourcePointer):
            pointer = resource_or_pointer
            if resource_id in namespace:
                primary = cast(ResourceContainer, namespace[resource_id])
                _merge(primary, pointer)
            else:
                namespace[resource_id] = pointer
        else:
            resource = resource_or_pointer
            # We found a potentially conflicting resource
            if resource_id in namespace:
                # Throw away duplicate resources when the object id is the same
                if namespace[resource_id] is resource:
                    continue
                else:
                    resource_name = getattr(resource, "name", str(resource.fqn))
                    raise DuplicateResourceException(
                        f"Duplicate {resource.resource_type.value} found: '{resource_name}'\n"
                        f"  Each resource must be defined only once.\n"
                        f"  Check your config files for duplicate definitions."
                    )
            else:
                namespace[resource_id] = resource

    return list(namespace.values())


def _get_databases(
    resource: ResourceContainer,
) -> list[Union[Database, ResourcePointer]]:
    return cast(
        list[Union[Database, ResourcePointer]],
        resource.items(resource_type=ResourceType.DATABASE),
    )


def _get_schemas(resource: ResourceContainer) -> list[Union[Schema, ResourcePointer]]:
    return cast(
        list[Union[Schema, ResourcePointer]],
        resource.items(resource_type=ResourceType.SCHEMA),
    )


def _get_schema_by_name(resource: ResourceContainer, name: Union[ResourceName, str]) -> Union[Schema, ResourcePointer]:
    return cast(
        Union[Schema, ResourcePointer],
        resource.find(name=name, resource_type=ResourceType.SCHEMA),
    )


def _get_public_schema(resource: ResourceContainer) -> Union[Schema, ResourcePointer]:
    return _get_schema_by_name(resource, "PUBLIC")


def _get_role_grants(resource: ResourceContainer) -> list[RoleGrant]:
    return cast(list[RoleGrant], resource.items(resource_type=ResourceType.ROLE_GRANT))


def _resource_scope_is_outside_blueprint_scope(resource_type: ResourceType, blueprint_scope: BlueprintScope) -> bool:
    resource_scope = RESOURCE_SCOPES[resource_type]
    if blueprint_scope == BlueprintScope.SCHEMA and (
        resource_type == ResourceType.SCHEMA or isinstance(resource_scope, (SchemaScope, TableScope))
    ):
        return False
    elif blueprint_scope == BlueprintScope.DATABASE and (
        resource_type == ResourceType.DATABASE or isinstance(resource_scope, (DatabaseScope, SchemaScope, TableScope))
    ):
        return False
    elif blueprint_scope == BlueprintScope.ACCOUNT:
        return False
    return True


class Blueprint:
    def __init__(
        self,
        name: Optional[str] = None,
        resources: Optional[list[Resource]] = None,
        dry_run: bool = False,
        sync_resources: Optional[list[ResourceType]] = None,
        exclude_resources: Optional[list[ResourceType]] = None,
        vars: Optional[dict] = None,
        vars_spec: Optional[list[dict]] = None,
        scope: Optional[str] = None,
        database: Optional[str] = None,
        schema: Optional[str] = None,
        threads: int = 8,
        use_account_usage: bool = False,
    ) -> None:
        self._config = BlueprintConfig(
            name=name,
            resources=resources,
            dry_run=dry_run,
            sync_resources=[ResourceType(item) for item in sync_resources] if sync_resources else None,
            exclude_resources=[ResourceType(item) for item in exclude_resources] if exclude_resources else None,
            vars=vars or {},
            vars_spec=vars_spec or [],
            scope=BlueprintScope(scope) if scope else None,
            database=ResourceName(database) if database else None,
            schema=ResourceName(schema) if schema else None,
            threads=max(1, threads),  # Ensure at least 1 thread
            use_account_usage=use_account_usage,
        )
        self._finalized = False
        self._staged: list[Resource] = []
        self._root = ResourcePointer(name="ACCOUNT", resource_type=ResourceType.ACCOUNT)
        self._levels: dict[URN, int] = {}  # Store dependency levels
        self.add(resources or [])

    @classmethod
    def from_config(cls, config: BlueprintConfig):
        blueprint = cls.__new__(cls)
        blueprint._config = config
        blueprint._staged = []
        blueprint._root = ResourcePointer(name="ACCOUNT", resource_type=ResourceType.ACCOUNT)
        blueprint._finalized = False
        blueprint._levels = {}  # Initialize dependency levels
        blueprint.add(config.resources or [])
        return blueprint

    def _raise_for_nonconforming_plan(self, session_ctx: SessionContext, plan: Plan):
        exceptions = []
        enterprise_resources: dict[str, list[str]] = {}

        for change in plan:
            if isinstance(change, UpdateResource):
                if "name" in change.delta:
                    exceptions.append(f"Renaming resources is not allowed (ref: {change.urn})")
                if change.resource_cls.resource_type == ResourceType.GRANT:
                    exceptions.append(f"Grants cannot be updated (ref: {change.urn})")

            # Edition exceptions - collect by resource type for better display
            if session_ctx["account_edition"] == AccountEdition.STANDARD:
                if isinstance(change, CreateResource) and AccountEdition.STANDARD not in change.resource_cls.edition:
                    label = change.urn.resource_label
                    if label not in enterprise_resources:
                        enterprise_resources[label] = []
                    enterprise_resources[label].append(str(change.urn.fqn))

            # Scope exceptions
            if self._config.scope:
                if _resource_scope_is_outside_blueprint_scope(change.urn.resource_type, self._config.scope):
                    exceptions.append(
                        f"Resource {change.urn} is out of scope ({self._config.scope}) for this blueprint"
                    )

        # Format enterprise edition errors
        if enterprise_resources:
            lines = ["These resources require Enterprise edition (current account is Standard):"]
            exclude_types = []
            for label, resources in enterprise_resources.items():
                exclude_types.append(label)
                lines.append(f"  {label}:")
                for resource in resources[:3]:
                    lines.append(f"    - {resource}")
                if len(resources) > 3:
                    lines.append(f"    ... and {len(resources) - 3} more")
            lines.append("")
            lines.append(f"Use --exclude to skip: --exclude {','.join(sorted(set(exclude_types)))}")
            exceptions.insert(0, "\n".join(lines))

        if exceptions:
            if len(exceptions) > 5:
                exception_block = "\n".join(exceptions[0:5]) + f"\n... and {len(exceptions) - 5} more"
            else:
                exception_block = "\n".join(exceptions)
            raise NonConformingPlanException("Non-conforming actions found in plan:\n" + exception_block)

    def _warning_for_nonconforming_plan(self, session_ctx: SessionContext, plan: Plan):
        warnings = []

        grant_to_system = False
        role_grant_to_system = False
        grant_on_all = False
        for change in plan:
            # System role exceptions
            if isinstance(change, CreateResource) and change.resource_cls.resource_type == ResourceType.GRANT:
                if change.after["to"] in SYSTEM_ROLES:
                    grant_to_system = True
                if change.after["grant_type"] == GrantType.ALL.value:
                    grant_on_all = True

            if isinstance(change, CreateResource) and change.resource_cls.resource_type == ResourceType.ROLE_GRANT:
                if change.after["role"] in SYSTEM_ROLES:
                    role_grant_to_system = True

        if grant_to_system:
            warnings.append(
                "Grants to system role found. They will be always recreated since system roles are not managed by Snowcap"
            )
        if role_grant_to_system:
            warnings.append(
                "Role grants to system role found. They will be always recreated since system roles are not managed by Snowcap"
            )
        if grant_on_all:
            warnings.append(
                "Grants of type ALL found. They will be always recreated since Snowcap does not compare the affected objects."
            )

        if warnings:
            logger.warning("\nActions found in plan that should be reviewed:")
            for warning in warnings:
                logger.warning(" - " + warning)

    def fetch_remote_state(self, session, manifest: Manifest) -> State:
        """Fetch remote state with parallel resource retrieval."""
        state = {}
        logger = logging.getLogger(__name__)
        session_ctx = data_provider.fetch_session(session)

        data_provider.use_secondary_roles(session, all=True)

        # Pre-populate ACCOUNT_USAGE caches if enabled
        # This avoids many individual SHOW GRANTS commands later
        if self._config.use_account_usage:
            data_provider.populate_account_usage_caches(session)

        if self._config.sync_resources:
            urns = [item for item in manifest.urns if item.resource_type not in self._config.sync_resources]
            # Pre-compute whether manifest has future grants (for GRANT sync optimization)
            has_future_grants = manifest_has_future_grants(manifest)
            future_grant_roles = manifest_future_grant_roles(manifest) if has_future_grants else set()
            for resource_type in self._config.sync_resources:
                # Pass include_future_grants=False for grants if manifest has no future grants
                # Also pass future_grant_roles to only query roles that have future grants
                list_kwargs: dict[str, Any] = {}
                if resource_type == ResourceType.GRANT:
                    list_kwargs["include_future_grants"] = has_future_grants
                    list_kwargs["future_grant_roles"] = future_grant_roles
                for fqn in data_provider.list_resource(session, resource_label_for_type(resource_type), **list_kwargs):
                    if self._config.scope == BlueprintScope.DATABASE and fqn.database != self._config.database:
                        continue
                    if self._config.scope == BlueprintScope.SCHEMA and fqn.schema != self._config.schema:
                        continue

                    urns.append(
                        URN(
                            resource_type=resource_type,
                            fqn=fqn,
                            account_locator=session_ctx["account_locator"],
                        )
                    )
        else:
            urns = list(manifest.urns)

        # Filter out excluded resource types
        if self._config.exclude_resources:
            urns = [urn for urn in urns if urn.resource_type not in self._config.exclude_resources]

        urns = list(set(urns))  # Deduplicate urns

        # Pre-compute which databases have param fields (for schema inheritance check)
        db_with_params = databases_with_param_fields(manifest)

        def _needs_params(urn: URN) -> bool:
            """Check if this resource needs parameter fields fetched."""
            resource_type = urn.resource_type

            # For resources not in manifest, skip params entirely
            # Params are only needed for comparing against manifest values
            # This applies to both sync_resources types (remote-only, will be deleted)
            # and non-sync types (shouldn't happen, we only fetch manifest URNs for those)
            if urn not in manifest.urns:
                return False

            # For schemas, use per-URN check (only PUBLIC schemas with db params, or schemas with own params)
            if resource_type == ResourceType.SCHEMA:
                return schema_urn_needs_params(urn, manifest, db_with_params)

            # For other resource types with param fields, check per-URN
            # This avoids fetching params for resources that don't specify param values
            # Works for both sync_resources and non-sync manifest resources
            return resource_urn_needs_params(urn, manifest)

        with ThreadPoolExecutor(max_workers=self._config.threads) as executor:
            future_to_urn = {
                executor.submit(data_provider.fetch_resource, session, urn, include_params=_needs_params(urn)): urn
                for urn in urns
            }
            for future in as_completed(future_to_urn):
                urn = future_to_urn[future]
                try:
                    data = future.result()
                    if data:
                        if self._config.sync_resources and urn.resource_type in self._config.sync_resources:
                            resource_cls = Resource.resolve_resource_cls(urn.resource_type, data)
                        else:
                            item = manifest[urn]
                            resource_cls = (
                                item.resource_cls
                                if isinstance(item, ManifestResource)
                                else Resource.resolve_resource_cls(urn.resource_type, data)
                            )
                        state[urn] = resource_cls.spec(**data).to_dict(session_ctx["account_edition"])
                    # If data is None, resource doesn't exist in Snowflake
                    # Don't add to state - reconciliation will create it
                except Exception as e:
                    logger.error(f"Failed to fetch resource {urn}: {e}")
                    raise  # Stop processing if any fetch fails

        # Check for references that are not in the state
        # Skip params and detailed queries for references - we just need to verify they exist
        checked_refs = []
        for parent, reference in manifest.refs:
            if reference in manifest.urns or reference in state or reference in checked_refs:
                continue
            is_public_schema = reference.resource_type == ResourceType.SCHEMA and reference.fqn.name == ResourceName(
                "PUBLIC"
            )
            try:
                data = data_provider.fetch_resource(session, reference, include_params=False, existence_only=True)
                if data is None and not is_public_schema:
                    available_names = [
                        str(u.fqn.name) for u in manifest.urns if u.resource_type == reference.resource_type
                    ]
                    raise MissingResourceException(
                        format_missing_resource_error(reference, parent, available_names),
                        missing_urn=reference,
                        required_by=parent,
                        suggestions=available_names,
                    )
                else:
                    checked_refs.append(reference)
            except Exception as e:
                if not is_public_schema:
                    logger.error(f"Error fetching reference {reference}: {e}")
                    raise
        return state

    def _resolve_vars(self):
        # Get all resources from the graph (after _build_resource_graph has run)
        all_resources = [r for r in _walk(self._root) if isinstance(r, Resource)]
        for resource in all_resources:
            resource._resolve_vars(self._config.vars, all_resources)

    def _resolve_role_refs(self):
        for resource in _walk(self._root):
            if isinstance(resource, ResourcePointer):
                continue
            resource._resolve_role_refs()

    def _build_resource_graph(self, session_ctx: SessionContext) -> None:
        """
        Convert the staged resources into a directed graph of resources
        """
        org_scoped, acct_scoped, db_scoped, schema_scoped = _split_by_scope(self._staged)
        self._staged = []

        # Create root node of the resource graph
        if len(org_scoped) > 0:
            raise Exception("Blueprint cannot contain an Account resource")

        # Merge account scoped pointers into their proper resource
        acct_scoped = _merge_pointers(acct_scoped)

        # Add all databases and other account scoped resources to the root
        for resource in acct_scoped:
            self._root.add(resource)

        if self._config.scope != BlueprintScope.ACCOUNT and self._config.database is not None:
            if len(acct_scoped) > 1:
                raise RuntimeError
            # The user has specified a database and added a resource to the config
            elif len(acct_scoped) == 1:
                scoped_database = acct_scoped[0]
                if scoped_database.resource_type != ResourceType.DATABASE:
                    raise RuntimeError(f"Expected a database, got {scoped_database.resource_type}")
                if scoped_database.name != self._config.database:
                    raise RuntimeError
            # The user has specified a database by name only
            else:
                scoped_database = ResourcePointer(name=self._config.database, resource_type=ResourceType.DATABASE)
                self._root.add(scoped_database)
                if self._config.schema is not None:
                    scoped_database.add(ResourcePointer(name=self._config.schema, resource_type=ResourceType.SCHEMA))

        # List all databases connected to root
        databases = _get_databases(self._root)

        # If the user didn't stage a database, create one from session context
        if len(databases) == 0 and (len(db_scoped) + len(schema_scoped) > 0):
            if session_ctx.get("database") is None:
                raise OrphanResourceException(
                    "Your config includes resources that require a database (schemas, tables, views, etc.) "
                    "but no database is defined.\n"
                    "  Add a database to your config:\n"
                    "    databases:\n"
                    "      - name: MY_DATABASE"
                )
            logger.warning(f"No database found in config, using database {session_ctx['database']} from session")
            self._root.add(ResourcePointer(name=session_ctx["database"], resource_type=ResourceType.DATABASE))
            databases = _get_databases(self._root)

        # Attach parentless schemas to the default database, if there is one
        for resource in db_scoped:
            if resource.container is None:
                if len(databases) == 1:
                    databases[0].add(resource)
                else:
                    raise OrphanResourceException(
                        f"Resource {resource.resource_type.value} '{resource.name}' has no database.\n"
                        f"  Your config has multiple databases. Specify which database this resource belongs to:\n"
                        f"    - name: {resource.name}\n"
                        f"      database: DATABASE_NAME"
                    )

        available_scopes = {}
        for database in databases:
            database_resources = list(database.items())
            _merge_pointers(database_resources)
            for schema in _get_schemas(database):
                available_scopes[f"{database.name}.{schema.name}"] = schema

        for resource in schema_scoped:
            if resource.container is None:
                if len(databases) == 1:
                    # When the blueprint is scoped all dangling resources should be assigned to the configured scope
                    if self._config.scope == BlueprintScope.SCHEMA and self._config.schema is not None:
                        scoped_schema = _get_schema_by_name(databases[0], self._config.schema)
                        scoped_schema.add(resource)
                        # TODO: figure out how to handle the case where the schema is already in the blueprint
                    else:
                        logger.warning(f"Resource {resource} has no schema, using {databases[0].name}.PUBLIC")
                        _get_public_schema(databases[0]).add(resource)
                else:
                    raise OrphanResourceException(
                        f"Resource {resource.resource_type.value} '{resource.name}' has no schema.\n"
                        f"  Your config has multiple databases. Specify which schema this resource belongs to:\n"
                        f"    - name: {resource.name}\n"
                        f"      database: DATABASE_NAME\n"
                        f"      schema: SCHEMA_NAME"
                    )
            elif isinstance(resource.container, ResourcePointer):
                schema_pointer = resource.container

                # We have a schema-scoped resource (eg a view) that has a resource pointer for the schema. The job is to connect
                # that resource into the tree
                #
                # If the schema pointer has no database, assume it lives in the only database we have
                if schema_pointer.container is None:
                    if len(databases) == 1:
                        databases[0].add(schema_pointer)
                    else:
                        raise OrphanResourceException(
                            f"Resource {resource.resource_type.value} '{resource.name}' references schema "
                            f"'{resource.container.name}' but no database is specified.\n"
                            f"  Your config has multiple databases. Specify which database the schema belongs to:\n"
                            f"    - name: {resource.name}\n"
                            f"      database: DATABASE_NAME\n"
                            f"      schema: {resource.container.name}"
                        )
                elif isinstance(schema_pointer.container, ResourcePointer):
                    expected_scope = f"{schema_pointer.container.name}.{schema_pointer.name}"
                    if expected_scope in available_scopes:
                        schema = available_scopes[expected_scope]
                        schema.add(resource)
                    else:
                        self._root.add(schema_pointer.container)

            for ref in resource.refs:
                resource_and_ref_share_scope = isinstance(ref.scope, resource.scope.__class__)
                if ref.container is None and resource.container is not None and resource_and_ref_share_scope:
                    if isinstance(ref, ResourcePointer):
                        # For ResourcePointers, set the container directly (for URN matching)
                        # but don't add them to the container's items (they're just dependency refs)
                        ref._container = resource.container
                    else:
                        # For actual Resource objects, add them to the container
                        resource.container.add(ref)

    def _create_tag_references(self) -> None:
        """
        Tag name resolution in Snowflake is special. Tags can be referenced
        by name only. If that tag name is unique in the account, the tag will be applied.
        If the tag name is not unique, the error "does not exist or not authorized" will be raised.

        To emulate this behavior, Blueprint will attempt to look up any referenced tags by name
        """
        taggables: list[TaggableResource] = []
        tags: list[Tag] = []
        for resource in _walk(self._root):
            if isinstance(resource, TaggableResource):
                taggables.append(resource)
            elif isinstance(resource, Tag):
                tags.append(resource)

        for resource in taggables:
            new_tags = {}
            if resource._tags is None:
                continue
            for tag_name, tag_value in resource._tags.items():
                identifier = parse_identifier(tag_name)
                if "database" in identifier or "schema" in identifier:
                    new_tags[tag_name] = tag_value
                else:
                    for tag in tags:
                        if tag.name == tag_name:
                            new_tags[str(tag.fqn)] = tag_value
                            break
                    else:
                        # We couldn't resolve the tag, so just use the tag name as is
                        new_tags[tag_name] = tag_value
            resource._tags = ResourceTags(new_tags)
            tag_ref = resource.create_tag_reference()
            if tag_ref:
                self._root.add(tag_ref)

    def _create_ownership_refs(self, session_ctx: SessionContext) -> None:
        role_grants: list[RoleGrant] = _get_role_grants(self._root)

        for resource in _walk(self._root):
            if isinstance(resource, ResourcePointer):
                continue
            elif isinstance(resource, RoleGrant):
                # Support ordering for role grants in a role tree
                for role_grant in role_grants:
                    if isinstance(resource.to, Role) and resource.to.name == role_grant.role.name:
                        resource.requires(role_grant)
            elif hasattr(resource._data, "owner"):
                owner = getattr(resource._data, "owner")

                # Misconfigured resource, owner should always be a Role
                if isinstance(owner, str):
                    raise RuntimeError(f"Owner of {resource} is a string, {owner}")

                owner = cast(ResourcePointer, owner)

                # Skip Snowflake-owned system resources (like INFORMATION_SCHEMA) that are owned by blank
                if owner.name == "":
                    continue

                # Require that a resource's owner role exists in remote state or has been added to the blueprint
                resource.requires(owner)

                # If the owner role isn't available in the session, try to find a role grant that can be used to
                # satisfy the requirement.
                if owner.name not in session_ctx["available_roles"]:
                    for role_grant in role_grants:
                        # Only look for role grants that match the owner role
                        if role_grant.role.name != owner.name:
                            continue

                        # Only look for role-to-role grants
                        if role_grant._data.to_role is None:
                            continue
                        resource.requires(role_grant)

                    # It's non-trivial to determine if an owner role is available in the current session because
                    # database roles aren't explicitly available in the session context
                    # else:
                    #     raise InvalidOwnerException(
                    #         f"Blueprint resource {resource} owner {resource._data.owner} must be granted to the current session"
                    #     )

    def _create_grandparent_refs(self) -> None:
        for resource in _walk(self._root):
            if isinstance(resource.scope, SchemaScope):
                resource.requires(resource.container.container)

    def _create_stage_privilege_refs(self) -> None:
        stage_grants: dict[str, list[Grant]] = {}

        for resource in _walk(self._root):
            if isinstance(resource, Grant):
                if resource._data.on_type == ResourceType.STAGE:
                    if resource._data.on not in stage_grants:
                        stage_grants[resource._data.on] = []
                    stage_grants[resource._data.on].append(resource)

        def _apply_refs(stage_grants):
            for stage in stage_grants.keys():
                read_grants = []
                write_grants = []
                for grant in stage_grants[stage]:
                    if grant._data.priv == "READ":
                        read_grants.append(grant)
                    elif grant._data.priv == "WRITE":
                        write_grants.append(grant)

                for w_grant in write_grants:
                    for r_grant in read_grants:
                        w_grant.requires(r_grant)

        _apply_refs(stage_grants)

    def _finalize_resources(self) -> None:
        for resource in _walk(self._root):
            resource._finalized = True

    def _finalize(self, session_ctx: SessionContext) -> None:
        if self._finalized:
            raise RuntimeError("Blueprint already finalized")
        self._finalized = True
        self._build_resource_graph(session_ctx)
        self._resolve_vars()
        self._resolve_role_refs()
        self._create_tag_references()
        self._create_ownership_refs(session_ctx)
        self._create_grandparent_refs()
        self._create_stage_privilege_refs()
        self._finalize_resources()

    def generate_manifest(self, session_ctx: SessionContext) -> Manifest:
        manifest = Manifest(account_locator=session_ctx["account_locator"])
        self._finalize(session_ctx)
        for resource in _walk(self._root):
            if isinstance(resource, Resource):
                # Skip resources that are in the exclude list
                if self._config.exclude_resources and resource.resource_type in self._config.exclude_resources:
                    continue
                # Skip grants that reference excluded resource types
                if (
                    self._config.exclude_resources
                    and resource.resource_type == ResourceType.GRANT
                    and hasattr(resource, "on_type")
                    and resource.on_type in self._config.exclude_resources
                ):
                    continue
                manifest.add(resource, session_ctx["account_edition"])
            else:
                raise RuntimeError(f"Unexpected object found in blueprint: {resource}")

        return manifest

    def _execute_change(self, session, commands: list[str]) -> None:
        """Execute a list of SQL commands for a single change."""
        logger = logging.getLogger(__name__)
        for sql in commands:
            if not self._config.dry_run:
                try:
                    execute(session, sql)
                except snowflake.connector.errors.ProgrammingError as err:
                    if err.errno == ALREADY_EXISTS_ERR:
                        logger.warning(f"Resource already exists: {sql}, skipping...")
                    elif err.errno == INVALID_GRANT_ERR:
                        logger.warning(f"Invalid grant: {sql}, skipping...")
                    elif err.errno == DOES_NOT_EXIST_ERR and sql.startswith(("REVOKE", "DROP")):
                        logger.warning(f"Resource does not exist: {sql}, skipping...")
                    else:
                        raise

    def plan(self, session) -> Plan:
        """Generate and store the plan, computing dependency levels."""
        logger = logging.getLogger(__name__)
        reset_cache()
        logger.debug("Using blueprint vars:")
        for key in self._config.vars.keys():
            logger.debug(f"  {key}")
        session_ctx = data_provider.fetch_session(session)
        manifest = self.generate_manifest(session_ctx)
        remote_state = self.fetch_remote_state(session, manifest)
        try:
            finished_plan = diff(remote_state, manifest)
            # Filter plan based on sync_resources:
            # - For sync_resources types: keep ALL changes (full sync, YML is source of truth)
            # - For non-sync_resources types: keep CREATE/UPDATE/TRANSFER but NOT DROP
            #   (only sync resources that are defined in YML, don't delete remote-only resources)
            if self._config.sync_resources:
                finished_plan = [
                    change
                    for change in finished_plan
                    if (
                        # Keep all changes for sync_resources types
                        change.urn.resource_type in self._config.sync_resources
                        # For non-sync types, keep everything except DROP
                        or not isinstance(change, DropResource)
                    )
                ]
            # Compute dependency levels
            resource_set = set(manifest.urns + list(remote_state.keys()))
            for ref in manifest.refs:
                resource_set.add(ref[0])
                resource_set.add(ref[1])
            self._levels = compute_levels(resource_set, set(manifest.refs))
        except Exception:
            logger.error("~" * 80 + "REMOTE STATE")
            logger.error(remote_state)
            logger.error("~" * 80 + "MANIFEST")
            logger.error(manifest)
            raise
        self._raise_for_nonconforming_plan(session_ctx, finished_plan)
        self._warning_for_nonconforming_plan(session_ctx, finished_plan)
        return finished_plan

    def apply(self, session, plan: Optional[Plan] = None) -> None:
        """Apply the plan with parallel execution of independent additive changes.

        At this point, we have a list of actions as a part of the plan. Each action is one of:
             1. ADD action (CREATE command)
             2. CHANGE action (one or many ALTER or SET PARAMETER commands)
             3. REMOVE action (DROP command, REVOKE command, or a rename operation)
             4. TRANSFER action (GRANT OWNERSHIP command)

         Each action requires:
             • a set of privileges necessary to run commands
             • the appropriate role to execute commands

         Once we've determined those things, we can compare the list of required roles and privileges
         against what we have access to in the session and the role tree."""

        def print_apply_summary(plan: Plan, phase: str = "start"):
            """Print a summary of what will be or was applied."""
            # Colors
            cyan = "\033[96m"
            blue = "\033[94m"
            green = "\033[92m"
            reset = "\033[0m"

            create_count = len([c for c in plan if isinstance(c, CreateResource)])
            update_count = len([c for c in plan if isinstance(c, UpdateResource)])
            transfer_count = len([c for c in plan if isinstance(c, TransferOwnership)])
            drop_count = len([c for c in plan if isinstance(c, DropResource)])

            if phase == "start":
                print(f"\n{cyan}»{reset} {blue}snowcap apply{reset}")
                print(
                    f"{cyan}»{reset} Applying: {green}{create_count}{reset} to create, {update_count} to update, {transfer_count} to transfer, {drop_count} to drop.\n"
                )
            else:
                print(
                    f"\n{cyan}»{reset} {green}Applied:{reset} {create_count} created, {update_count} updated, {transfer_count} transferred, {drop_count} dropped.\n"
                )

        def execute_commands_in_parallel(commands):
            """Execute a list of SQL commands in parallel using a thread pool."""
            with ThreadPoolExecutor(max_workers=self._config.threads) as executor:
                future_to_change = {
                    executor.submit(
                        self._execute_change,
                        session,
                        c["commands"],
                    ): c["change"]
                    for c in commands
                }
                for future in as_completed(future_to_change):
                    change = future_to_change[future]
                    try:
                        future.result()
                    except Exception as e:
                        logger.error(f"Failed to execute change {change}: {e}")
                        raise

        def process_commands(commands, roles, available_roles):
            # Map changes to their levels (default to 0 if not in self._levels)
            levels = {c["change"].urn: self._levels.get(c["change"].urn, 0) for c in commands}
            max_level = max(levels.values()) if levels else 0

            roles_dynamically_added = set()
            # Execute additive changes by level
            for level in range(max_level + 1):
                commands_at_level = [c for c in commands if levels.get(c["change"].urn, 0) == level]
                for role in roles:
                    # Execute additive changes in current level by role
                    commands_at_role_level = [c for c in commands_at_level if c["role"] == role]
                    if commands_at_role_level:
                        logger.debug(f"Executing level {level} role {role} with {len(commands_at_role_level)} changes")
                        needs_new_role = role not in available_roles
                        if needs_new_role:
                            execute(session, f"GRANT ROLE {role} TO USER {session_ctx['user']}")
                            roles_dynamically_added.add(role)
                        execute(session, f"USE ROLE {role}")
                        execute_commands_in_parallel(commands_at_role_level)

            # Revoke dynamically added roles
            for role in roles_dynamically_added:
                execute(session, f"REVOKE ROLE {role} FROM USER {session_ctx['user']}")

        # TODO: cursor setup, including query tag

        logger = logging.getLogger(__name__)
        if plan is None:
            plan = self.plan(session)

        # Print summary of what will be applied
        print_apply_summary(plan, "start")

        if not plan:
            return

        session_ctx = data_provider.fetch_session(session)
        _raise_if_plan_would_drop_session_user(session_ctx, plan)

        sql_commands_per_change = compile_plan_to_sql(session_ctx, plan)
        roles_list: list[Any] = []
        additive_commands = []
        destructive_commands = []
        for command in sql_commands_per_change:
            roles_list.append(command["role"])
            if isinstance(command["change"], (CreateResource, UpdateResource, TransferOwnership)):
                additive_commands.append(command)
            elif isinstance(command["change"], DropResource):
                destructive_commands.append(command)
        roles_set = set(roles_list)

        # Process additive changes
        process_commands(additive_commands, roles_set, session_ctx["available_roles"])

        # Process destructive changes
        process_commands(destructive_commands, roles_set, session_ctx["available_roles"])

        # Print completion summary
        print_apply_summary(plan, "end")

    def _add(self, resource: Resource):
        if self._finalized:
            raise Exception("Cannot add resources to a finalized blueprint")
        if not isinstance(resource, Resource):
            raise Exception(f"Expected a Resource, got {type(resource)} -> {resource}")
        if resource._finalized:
            raise Exception("Cannot add a finalized resource to a blueprint")
        self._staged.append(resource)

    def add(self, *resources):
        if isinstance(resources[0], list):
            resources = resources[0]
        for resource in resources:
            self._add(resource)


def owner_for_change(change: ResourceChange) -> Optional[ResourceName]:
    if isinstance(change, CreateResource) and "owner" in change.after:
        return ResourceName(change.after["owner"])
    elif isinstance(change, UpdateResource) and "owner" in change.after:
        # TRANSFER actions occur strictly after CHANGE actions, so we use the before owner
        # as the role for the change
        return ResourceName(change.before["owner"])
    elif isinstance(change, DropResource) and "owner" in change.before:
        return ResourceName(change.before["owner"])
    elif isinstance(change, TransferOwnership):
        return ResourceName(change.from_owner)
    else:
        return None


def execution_strategy_for_change(
    change: ResourceChange,
    available_roles: list[ResourceName],
    default_role: ResourceName,
) -> tuple[ResourceName, bool]:

    change_owner = owner_for_change(change)

    if resource_type_is_grant(change.urn.resource_type):
        # 2024-10-22: maybe the better thing to do is check role privs selectively
        if isinstance(change, CreateResource) and change.urn.resource_type == ResourceType.GRANT:
            execution_role = system_role_for_priv(change.after["priv"])
            if execution_role and execution_role in available_roles:
                return ResourceName(execution_role), False

        if "SECURITYADMIN" in available_roles:
            return ResourceName("SECURITYADMIN"), False

        return default_role, False

    elif change.urn.resource_type == ResourceType.TAG_REFERENCE:
        # There are two ways you can create a tag reference:
        # 1. You have the global APPLY TAGS priv on the account (given to ACCOUNTADMIN by default)
        # 2. You have APPLY privilege on the TAG object AND you have ownership of the tagged object
        if "ACCOUNTADMIN" in available_roles:
            return ResourceName("ACCOUNTADMIN"), False

        return default_role, False

    elif change.urn.resource_type == ResourceType.TAG_MASKING_POLICY_REFERENCE:
        # Tag-based masking policy references require the APPLY MASKING POLICY privilege
        # which is granted to ACCOUNTADMIN by default
        if "ACCOUNTADMIN" in available_roles:
            return ResourceName("ACCOUNTADMIN"), False

        return default_role, False

    elif change.urn.resource_type == ResourceType.RESOURCE_MONITOR:
        # For some reason Snowflake chose to not have a priv type for resource monitors.
        # Only ACCOUNTADMIN can create them.
        if "ACCOUNTADMIN" in available_roles:
            return ResourceName("ACCOUNTADMIN"), False
        raise MissingPrivilegeException(
            "ACCOUNTADMIN role is required to manage resource monitors.\n"
            "  Grant ACCOUNTADMIN to your user or use a different connection."
        )

    elif change.urn.resource_type == ResourceType.ACCOUNT_PARAMETER:
        if "ACCOUNTADMIN" in available_roles:
            return ResourceName("ACCOUNTADMIN"), False
        raise MissingPrivilegeException(
            "ACCOUNTADMIN role is required to manage account parameters.\n"
            "  Grant ACCOUNTADMIN to your user or use a different connection."
        )

    elif change.urn.resource_type == ResourceType.SCANNER_PACKAGE:
        if "ACCOUNTADMIN" in available_roles:
            return ResourceName("ACCOUNTADMIN"), False
        raise MissingPrivilegeException(
            "ACCOUNTADMIN role is required to manage scanner packages.\n"
            "  Grant ACCOUNTADMIN to your user or use a different connection."
        )

    elif isinstance(change, (UpdateResource, DropResource, TransferOwnership)):
        if change_owner:
            return change_owner, False
        else:
            raise MissingPrivilegeException(
                f"Insufficient privileges to modify {change.urn.resource_label} '{change.urn.fqn}'.\n"
                f"  You need ownership or appropriate grants on this resource."
            )
    elif isinstance(change, CreateResource):
        if isinstance(change.resource_cls.scope, AccountScope):
            create_priv = CREATE_PRIV_FOR_RESOURCE_TYPE[change.urn.resource_type]

            # SHARE ownership cannot be changed
            if change.urn.resource_type == ResourceType.SHARE:
                if change_owner is None:
                    raise RuntimeError
                return change_owner, False

            system_role = system_role_for_priv(create_priv)
            if system_role and system_role in available_roles:
                transfer_ownership = system_role != change_owner
                return ResourceName(system_role), transfer_ownership
            raise MissingPrivilegeException(
                f"Role {system_role} is required to create {change.urn.resource_label} resources.\n"
                f"  Grant {system_role} to your user:\n"
                f"    GRANT ROLE {system_role} TO USER your_user;"
            )
        elif isinstance(change.resource_cls.scope, (DatabaseScope, SchemaScope)) and change.container:
            container_owner = ResourceName(change.container[1])
            transfer_ownership = container_owner != change_owner
            if transfer_ownership and change.urn.resource_type == ResourceType.NOTEBOOK:
                raise Exception("Notebook ownership cannot be transferred")
            return container_owner, transfer_ownership

    raise RuntimeError(f"Unhandled change type: {change}")


def sql_commands_for_change(
    change: ResourceChange,
    available_roles: list[ResourceName],
    default_role: ResourceName,
) -> tuple[ResourceName, list[str]]:
    """
    In Snowflake's RBAC model, a session has an active role, and zero or more secondary roles.

    The active role of a session is set as follows:
    - When a session is started:
        - If the session is configured with a role, that is the active role
        - Otherwise, if the user of the session has a default_role set, and that role exists, that is the active role
        - Otherwise, the PUBLIC role is activated (PUBLIC cannot be revoked)
    - Any time the USE ROLE command is run, the active role is switched

    A session may run any command thats allowed by the active role or any role downstream from it in the role hierarchy.
    When secondary roles are active (by running the command USE SECONDARY ROLES ALL), then the session may also run any
    command that any secondary role or a role downstream from it is allowed to run.

    However, when a CREATE command is run, only the active role is considered. This is because the role that
    creates a new resource owns that resource by default. There are some exceptions with GRANTS.

    For those reasons, we generally don't have to worry about the current role as long as we have activated secondary roles.
    The exception is when creating new resources
    """

    before_change_cmd = []
    change_cmd = None
    after_change_cmd = []

    execution_role, transfer_owner = execution_strategy_for_change(
        change,
        available_roles,
        default_role,
    )

    if isinstance(change, CreateResource):
        change_cmd = lifecycle.create_resource(change.urn, change.after, change.resource_cls.props)
        if transfer_owner:
            after_change_cmd.append(
                lifecycle.transfer_resource(
                    change.urn,
                    owner=change.after["owner"],
                    owner_resource_type=infer_role_type_from_name(change.after["owner"]),
                    copy_current_grants=True,
                )
            )
            # SPECIAL CASE: when creating a database with a custom owner that we will transfer ownership to,
            # we also need to transfer ownership of the public schema to that role. This replicates the behavior
            # if we were to create the database with a custom owner directly
            if change.urn.resource_type == ResourceType.DATABASE:
                after_change_cmd.append(
                    lifecycle.transfer_resource(
                        public_schema_urn(change.urn),
                        owner=change.after["owner"],
                        owner_resource_type=infer_role_type_from_name(change.after["owner"]),
                        copy_current_grants=True,
                    )
                )

            if change.urn.resource_type == ResourceType.SCANNER_PACKAGE:
                after_change_cmd.append(lifecycle.update_resource(change.urn, {}, change.resource_cls.props))
    elif isinstance(change, UpdateResource):
        props = Resource.props_for_resource_type(change.urn.resource_type, change.after)
        change_cmd = lifecycle.update_resource(change.urn, change.delta, props)
    elif isinstance(change, DropResource):
        if transfer_owner:
            before_change_cmd.append(
                lifecycle.transfer_resource(
                    change.urn,
                    owner=str(execution_role),
                    owner_resource_type=infer_role_type_from_name(str(execution_role)),
                    copy_current_grants=True,
                )
            )
        change_cmd = lifecycle.drop_resource(
            change.urn,
            change.before,
            if_exists=True,
        )
    elif isinstance(change, TransferOwnership):
        change_cmd = lifecycle.transfer_resource(
            change.urn,
            owner=change.to_owner,
            owner_resource_type=infer_role_type_from_name(change.to_owner),
            copy_current_grants=True,
        )

    all_cmds = before_change_cmd + [change_cmd] + after_change_cmd
    return execution_role, [cmd for cmd in all_cmds if cmd is not None]


def compile_plan_to_sql(session_ctx: SessionContext, plan: Plan) -> list[dict]:
    """Compile the plan into a list of SQL command lists, one per change."""
    sql_commands_per_change = []
    available_roles = session_ctx["available_roles"].copy()
    default_role = session_ctx["role"]
    for change in plan:
        if isinstance(change, CreateResource):
            if change.urn.resource_type == ResourceType.ROLE:
                available_roles.append(ResourceName(change.after["name"]))
            elif change.urn.resource_type == ResourceType.ROLE_GRANT:
                if change.after["to_role"] in available_roles:
                    available_roles.append(ResourceName(change.after["role"]))
    for change in plan:
        role, commands = sql_commands_for_change(change, available_roles, default_role)
        sql_commands_per_change.append({"role": role, "commands": commands, "change": change})
    return sql_commands_per_change


def compute_levels(resource_set: Set[URN], references: Set[tuple[URN, URN]]) -> dict[URN, int]:
    """
    Compute the dependency level for each URN based on references.

    In this context, a reference (parent, ref) means that parent depends on ref.
    For example, if we have (A, B), it means A depends on B, so B must be created before A.

    The level of a resource indicates its position in the dependency hierarchy:
    - Level 0: Resources with no dependencies
    - Level 1: Resources that depend only on level 0 resources
    - Level 2: Resources that depend on level 0 or level 1 resources
    - And so on...

    This function uses Kahn's algorithm for topological sorting to assign levels.
    """
    logger = logging.getLogger(__name__)
    logger.debug(f"Computing levels for {len(resource_set)} resources with {len(references)} references")

    # Make a copy of the resource set to avoid modifying the original
    resources = set(resource_set)

    # Initialize in-degrees dictionary
    in_degrees = {urn: 0 for urn in resources}

    # Build adjacency list for faster processing
    adjacency_list: dict[URN, list[URN]] = {urn: [] for urn in resources}

    # Compute in-degrees and build adjacency list
    # Note: (parent, ref) means parent depends on ref
    for parent, ref in references:
        in_degrees[parent] += 1  # Parent depends on ref, so increment parent's in-degree
        adjacency_list[ref].append(parent)  # ref -> parent (ref is required by parent)
        logger.debug(f"Dependency: {parent} depends on {ref}")

    levels = {}
    # Start with nodes that have no dependencies (in-degree = 0)
    queue = [urn for urn in resources if in_degrees[urn] == 0]
    logger.debug(f"Initial queue with {len(queue)} resources: {queue}")

    if not queue:
        # If there are no nodes with in-degree 0, there must be a cycle
        logger.error("No resources with in-degree 0 found, graph contains cycles")
        raise NotADAGException("Dependency graph contains cycles")

    current_level = 0
    processed_count = 0

    while queue:
        logger.debug(f"Processing level {current_level} with {len(queue)} resources")
        next_queue = []

        # All nodes in the current queue are at the current level
        for urn in queue:
            levels[urn] = current_level
            processed_count += 1
            logger.debug(f"Assigned level {current_level} to {urn}")

            # Process all resources that depend on this one
            for dependent in adjacency_list[urn]:
                in_degrees[dependent] -= 1
                logger.debug(f"Decremented in_degree for {dependent} to {in_degrees[dependent]}")

                if in_degrees[dependent] == 0:
                    logger.debug(f"Adding {dependent} to next_queue for level {current_level + 1}")
                    next_queue.append(dependent)

        queue = next_queue
        current_level += 1

        # Safety check to prevent infinite loops
        if not queue and processed_count < len(resources):
            remaining = [urn for urn in resources if urn not in levels]
            logger.error(f"Queue empty but {len(remaining)} resources not processed: {remaining}")
            logger.error(f"Remaining in_degrees: {[(urn, deg) for urn, deg in in_degrees.items() if urn in remaining]}")

            # Find cycles in the remaining nodes
            cycle_candidates = [urn for urn, deg in in_degrees.items() if deg > 0 and urn not in levels]
            if cycle_candidates:
                logger.error(f"Potential cycle involving: {cycle_candidates}")

            raise NotADAGException("Dependency graph contains cycles")

    logger.debug(f"Processed {processed_count}/{len(resources)} resources")
    logger.debug(f"Final levels: {levels}")

    # This check should never fail if the algorithm is implemented correctly
    if len(levels) != len(resources):
        unprocessed = resources - set(levels.keys())
        logger.error(f"Not all resources assigned levels. Unprocessed: {unprocessed}")
        raise NotADAGException("Dependency graph contains cycles")

    return levels


def diff(remote_state: State, manifest: Manifest) -> list:
    """Compute the differences between remote state and manifest"""

    def _container_descriptor(urn: URN) -> Optional[ContainerDescriptor]:
        """
        Given the URN of a resource, return a descriptor of the container that owns it.
        """
        if isinstance(RESOURCE_SCOPES[urn.resource_type], AccountScope):
            return None

        container_urn = _container_urn(urn)
        if container_urn in remote_state:
            if "owner" in remote_state[container_urn]:
                container_owner = remote_state[container_urn]["owner"]
            else:
                raise Exception(f"Remote state for {container_urn} is missing owner -> {remote_state[container_urn]}")
        else:
            manifest_item = manifest[container_urn]
            if isinstance(manifest_item, ManifestResource):
                container_owner = manifest_item.data["owner"]
            else:
                raise MissingResourceException(
                    format_missing_container_error(container_urn),
                    missing_urn=container_urn,
                )

        return (container_urn, container_owner)

    def _diff_resource_data(lhs: dict, rhs: dict) -> dict:
        delta = {}
        for field_name in lhs.keys():
            lhs_value = lhs[field_name]
            rhs_value = rhs[field_name]
            # Skip fields where manifest value is None - means "use Snowflake default/inherit"
            if rhs_value is None:
                continue
            if lhs_value != rhs_value:
                delta[field_name] = rhs_value
        return delta

    changes: list[ResourceChange] = []
    state_urns = set(remote_state.keys())
    manifest_urns = set(manifest.urns)

    # Debug logging for tag masking policy references
    tmpr_state_urns = [u for u in state_urns if u.resource_type == ResourceType.TAG_MASKING_POLICY_REFERENCE]
    tmpr_manifest_urns = [u for u in manifest_urns if u.resource_type == ResourceType.TAG_MASKING_POLICY_REFERENCE]
    if tmpr_state_urns or tmpr_manifest_urns:
        logger.debug("TAG_MASKING_POLICY_REFERENCE comparison:")
        logger.debug(f"  State URNs ({len(tmpr_state_urns)}):")
        for urn in tmpr_state_urns:
            logger.debug(f"    {urn} (hash={hash(urn)})")
        logger.debug(f"  Manifest URNs ({len(tmpr_manifest_urns)}):")
        for urn in tmpr_manifest_urns:
            logger.debug(f"    {urn} (hash={hash(urn)})")
            # Check if this URN matches any state URN
            for state_urn in tmpr_state_urns:
                if urn == state_urn:
                    logger.debug("      MATCHES state URN!")
                else:
                    logger.debug(f"      != {state_urn}")
                    logger.debug(f"        fqn match: {urn.fqn == state_urn.fqn}")
                    logger.debug(f"        resource_type match: {urn.resource_type == state_urn.resource_type}")
                    logger.debug(f"        account_locator match: {urn.account_locator == state_urn.account_locator}")

    grant_on_all_resources = [
        r
        for r in manifest.resources
        if not isinstance(r, ResourcePointer)
        and r.resource_cls == Grant
        and r.data["grant_type"] == GrantType.ALL.value
    ]

    # Resources in remote state but not in the manifest should be removed
    for urn in state_urns - manifest_urns:
        remote_res = remote_state[urn]
        # If there are ALL grants and the current resource is included we should not drop it
        if grant_on_all_resources and remote_res.get("grant_type") == GrantType.OBJECT.value:
            matching_grants = [
                r
                for r in grant_on_all_resources
                if r.data["priv"] == remote_res["priv"]
                and r.data["to"] == remote_res["to"]
                and r.data["items_type"] == remote_res["on_type"]
                and r.data["on"] == ".".join(remote_res["on"].split(".")[:-1])
            ]
            if matching_grants:
                continue
        else:
            changes.append(DropResource(urn, remote_state[urn]))

    # Resources in the manifest but not in remote state should be added
    for urn in manifest_urns - state_urns:
        manifest_item = manifest[urn]
        if isinstance(manifest_item, ResourcePointer):
            available_names = [str(u.fqn.name) for u in manifest_urns if u.resource_type == urn.resource_type]
            raise MissingResourceException(
                format_missing_pointer_error(urn, available_names),
                missing_urn=urn,
                suggestions=available_names,
            )
        elif isinstance(manifest_item, ManifestResource) and not manifest_item.implicit:
            changes.append(
                CreateResource(
                    urn,
                    manifest_item.resource_cls,
                    _container_descriptor(urn),
                    manifest_item.data,
                )
            )

    # Resources in both should be compared
    for urn in state_urns & manifest_urns:
        manifest_item = manifest[urn]
        if isinstance(manifest_item, ResourcePointer):
            continue
        delta = _diff_resource_data(remote_state[urn], manifest_item.data)
        owner_attr = delta.pop("owner", None)

        replace_resource = False
        create_resource = False
        ignore_fields = set()

        for attr in delta.keys():
            attr_metadata = manifest_item.resource_cls.spec.get_metadata(attr)
            change_requires_replacement = attr_metadata.triggers_replacement
            change_triggers_create = attr_metadata.triggers_create
            change_is_fetchable = attr_metadata.fetchable
            change_is_known_after_apply = attr_metadata.known_after_apply
            change_should_be_ignored = attr in manifest_item.lifecycle.ignore_changes or attr_metadata.ignore_changes
            if change_requires_replacement:
                replace_resource = True
                break
            elif change_triggers_create:
                create_resource = True
                break
            elif not change_is_fetchable:
                ignore_fields.add(attr)
            elif change_is_known_after_apply:
                ignore_fields.add(attr)
            elif change_should_be_ignored:
                ignore_fields.add(attr)

        if replace_resource:
            raise NotImplementedError("replace_resource")

        if create_resource:
            changes.append(
                CreateResource(
                    urn,
                    manifest_item.resource_cls,
                    _container_descriptor(urn),
                    manifest_item.data,
                )
            )
            continue

        delta = {k: v for k, v in delta.items() if k not in ignore_fields}
        if delta:
            changes.append(
                UpdateResource(
                    urn,
                    manifest_item.resource_cls,
                    remote_state[urn],
                    manifest_item.data,
                    delta,
                )
            )

        # Force transfers to occur after all other attribute changes
        if owner_attr:
            owner_metadata = manifest_item.resource_cls.spec.get_metadata("owner")
            owner_is_fetchable = owner_metadata.fetchable
            owner_changes_should_be_ignored = (
                "owner" in manifest_item.lifecycle.ignore_changes or owner_metadata.ignore_changes
            )

            if not owner_is_fetchable or owner_changes_should_be_ignored:
                continue

            changes.append(
                TransferOwnership(
                    urn,
                    manifest_item.resource_cls,
                    remote_state[urn]["owner"],
                    manifest_item.data["owner"],
                )
            )

    return changes


def _container_urn(resource_urn: URN) -> URN:
    scope = RESOURCE_SCOPES[resource_urn.resource_type]
    container_urn: URN

    if isinstance(scope, AccountScope):
        container_urn = resource_urn.account()
    elif isinstance(scope, DatabaseScope):
        container_urn = resource_urn.database()
    elif isinstance(scope, SchemaScope):
        container_urn = resource_urn.schema()
    else:
        raise NotImplementedError(f"Unsupported resource scope: {scope}")
    return container_urn
