from __future__ import annotations

from enum import Enum
from typing import TYPE_CHECKING, Any, Sequence, TypeVar, overload

from infrahub_sdk.utils import is_valid_uuid
from infrahub_sdk.uuidt import UUIDT

from infrahub.computed_attribute.jinja2 import InfrahubJinja2Template
from infrahub.core import registry
from infrahub.core.changelog.models import NodeChangelog
from infrahub.core.constants import (
    GLOBAL_BRANCH_NAME,
    OBJECT_TEMPLATE_RELATIONSHIP_NAME,
    SYSTEM_USER_ID,
    BranchSupportType,
    ComputedAttributeKind,
    InfrahubKind,
    RelationshipCardinality,
    RelationshipKind,
)
from infrahub.core.constants.schema import RESOURCE_POOL_REL_SUFFIX, SchemaElementPathType
from infrahub.core.metadata.interface import MetadataInterface
from infrahub.core.metadata.model import MetadataInfo
from infrahub.core.protocols import CoreNumberPool, CoreObjectTemplate
from infrahub.core.query.node import NodeCheckIDQuery, NodeCreateAllQuery, NodeDeleteQuery, NodeUpdateMetadataQuery
from infrahub.core.schema import (
    AttributeSchema,
    NodeSchema,
    NonGenericSchemaTypes,
    ProfileSchema,
    RelationshipSchema,
    TemplateSchema,
)
from infrahub.core.schema.attribute_parameters import NumberPoolParameters
from infrahub.core.timestamp import Timestamp
from infrahub.exceptions import InitializationError, NodeNotFoundError, PoolExhaustedError, ValidationError
from infrahub.pools.default_allocator import DefaultPoolAllocator
from infrahub.pools.noop_allocator import NoOpPoolAllocator
from infrahub.profiles.mandatory_fields_checker import ProfilesMandatoryFieldGetter
from infrahub.templates.node_applier import NodeTemplateApplier
from infrahub.types import ATTRIBUTE_TYPES

from ...graphql.constants import KIND_GRAPHQL_FIELD_NAME
from ...log import get_logger
from ..attribute import BaseAttribute
from ..query.relationship import RelationshipDeleteAllQuery
from ..relationship import RelationshipManager
from .base import BaseNode, BaseNodeMeta, BaseNodeOptions
from .node_property_attribute import DisplayLabel, HumanFriendlyIdentifier

if TYPE_CHECKING:
    from typing_extensions import Self

    from infrahub.core.branch import Branch
    from infrahub.core.creation_context import NodeCreationContext
    from infrahub.core.schema.schema_branch import SchemaBranch
    from infrahub.core.schema.schema_branch_display import TemplateLabel
    from infrahub.core.schema.schema_branch_hfid import HFIDDefinition
    from infrahub.database import InfrahubDatabase

SchemaProtocol = TypeVar("SchemaProtocol")

# ---------------------------------------------------------------------------------------
# Type of Nodes
#  - Core node, wo/ branch : Branch, MergeRequest, Comment
#  - Core node, w/ branch : Repository, GQLQuery, Permission, Account, Groups, Schema
#  - Location Node : Location,
#  - Select Node : Status, Role, Manufacturer etc ..
#  -
# ---------------------------------------------------------------------------------------

log = get_logger()


class Node(BaseNode, MetadataInterface, metaclass=BaseNodeMeta):
    @classmethod
    def __init_subclass_with_meta__(
        cls, _meta: BaseNodeOptions | None = None, default_filter: None = None, **options: dict[str, Any]
    ) -> None:
        if not _meta:
            _meta = BaseNodeOptions(cls)

        _meta.default_filter = default_filter
        super().__init_subclass_with_meta__(_meta=_meta, **options)

    def __init__(self, schema: NodeSchema | ProfileSchema | TemplateSchema, branch: Branch, at: Timestamp) -> None:
        super().__init__()
        self._schema: NodeSchema | ProfileSchema | TemplateSchema = schema
        self._branch: Branch = branch
        self._at: Timestamp = at
        self._existing: bool = False
        self._metadata = MetadataInfo()

        self.id: str = None
        self.db_id: str = None

        self._source: Node | None = None
        self._owner: Node | None = None
        self._is_protected: bool = None
        self._computed_jinja2_attributes: list[str] = []
        self._profile_provided_attrs: set[str] = set()
        self._profile_provided_rels: set[str] = set()

        self._display_label: DisplayLabel | None = None
        self._human_friendly_id: HumanFriendlyIdentifier | None = None

        # Lists of attributes and relationships names
        self._attributes: list[str] = []
        self._relationships: list[str] = []
        self._node_changelog: NodeChangelog | None = None
        self._creation_context: NodeCreationContext | None = None

    def _set_created_at(self, value: Timestamp | None) -> None:
        self._metadata.created_at = value

    def _set_created_by(self, value: str | None) -> None:
        self._metadata.created_by = value

    def _set_updated_at(self, value: Timestamp | None) -> None:
        self._metadata.updated_at = value

    def _set_updated_by(self, value: str | None) -> None:
        self._metadata.updated_by = value

    def _get_created_at(self) -> Timestamp | None:
        return self._metadata.created_at

    def _get_created_by(self) -> str | None:
        return self._metadata.created_by

    def _get_updated_at(self) -> Timestamp | None:
        return self._metadata.updated_at

    def _get_updated_by(self) -> str | None:
        return self._metadata.updated_by

    def get_schema(self) -> NonGenericSchemaTypes:
        return self._schema

    def get_branch(self) -> Branch:
        return self._branch

    def get_kind(self) -> str:
        """Return the main Kind of the Object."""
        return self._schema.kind

    def get_id(self) -> str:
        """Return the ID of the node"""
        if self.id:
            return self.id

        raise InitializationError("The node has not been saved yet and doesn't have an id")

    def get_attribute(self, name: str) -> BaseAttribute:
        attribute = getattr(self, name, None)
        if not isinstance(attribute, BaseAttribute):
            raise ValueError(f"{name} is not an attribute of {self.get_kind()}")
        return attribute

    def get_relationship(self, name: str) -> RelationshipManager:
        relationship = getattr(self, name, None)
        if not isinstance(relationship, RelationshipManager):
            raise ValueError(f"{name} is not a relationship of {self.get_kind()}")
        return relationship

    def get_relationship_by_identifier(self, identifier: str) -> RelationshipManager:
        for rel_schema in self._schema.relationships:
            if rel_schema.identifier == identifier:
                return self.get_relationship(rel_schema.name)
        raise ValueError(f"Unable to find the relationship with the identifier {identifier} for {self.get_kind()}")

    def uses_profiles(self) -> bool:
        for attr_name in self.get_schema().attribute_names:
            try:
                node_attr = self.get_attribute(attr_name)
            except ValueError:
                continue
            if node_attr and node_attr.is_from_profile:
                return True
        return False

    async def get_hfid(self, db: InfrahubDatabase, include_kind: bool = False) -> list[str] | None:
        """Return the Human friendly id of the node."""
        if not self._schema.human_friendly_id:
            return None

        hfid_values: list[str] | None = None
        if self._human_friendly_id:
            hfid_values = self._human_friendly_id.get_value(node=self, at=self._at)
        if not hfid_values:
            hfid_values = [await self.get_path_value(db=db, path=item) for item in self._schema.human_friendly_id]

        hfid = [value for value in hfid_values if value is not None]
        return [self.get_kind()] + hfid if include_kind else hfid

    async def get_hfid_as_string(self, db: InfrahubDatabase, include_kind: bool = False) -> str | None:
        """Return the Human friendly id of the node in string format separated with a dunder (__) ."""
        hfid = await self.get_hfid(db=db, include_kind=include_kind)
        if not hfid:
            return None
        return "__".join(hfid)

    def has_human_friendly_id(self) -> bool:
        return self._human_friendly_id is not None

    async def add_human_friendly_id(self, db: InfrahubDatabase) -> None:
        if self._human_friendly_id:
            return

        self._human_friendly_id = HumanFriendlyIdentifier(
            node_schema=self._schema, template=self._schema.human_friendly_id
        )
        await self._human_friendly_id.compute(db=db, node=self)

    async def get_display_label(self, db: InfrahubDatabase) -> str:
        if self._display_label and (value := self._display_label.get_value(node=self, at=self._at)):
            return value

        return await self.render_display_label(db=db)

    def has_display_label(self) -> bool:
        return self._display_label is not None

    async def add_display_label(self, db: InfrahubDatabase) -> None:
        if self._display_label:
            return

        self._display_label = DisplayLabel(node_schema=self._schema, template=self._schema.display_label)
        await self._display_label.compute(db=db, node=self)

    async def get_path_value(self, db: InfrahubDatabase, path: str) -> str:
        schema_path = self._schema.parse_schema_path(
            path=path, schema=db.schema.get_schema_branch(name=self._branch.name)
        )

        if not schema_path.has_property:
            raise ValueError(f"Unable to retrieve the value of a path without property {path!r} on {self.get_kind()!r}")

        if (
            schema_path.is_type_relationship
            and schema_path.relationship_schema.cardinality == RelationshipCardinality.MANY
        ):
            raise ValueError(
                f"Unable to retrieve the value of a path on a relationship of cardinality many {path!r} on {self.get_kind()!r}"
            )

        if schema_path.is_type_attribute:
            attr = getattr(self, schema_path.attribute_schema.name)
            return getattr(attr, schema_path.attribute_property_name)

        if schema_path.is_type_relationship:
            relm: RelationshipManager = getattr(self, schema_path.relationship_schema.name)
            await relm.resolve(db=db)
            node = await relm.get_peer(db=db)
            attr = getattr(node, schema_path.attribute_schema.name)
            return getattr(attr, schema_path.attribute_property_name)

    def get_labels(self) -> list[str]:
        """Return the labels for this object, composed of the kind
        and the list of Generic this object is inheriting from."""
        labels: list[str] = []
        if isinstance(self._schema, NodeSchema):
            labels = [self.get_kind()] + self._schema.inherit_from
            if (
                self._schema.namespace not in ["Schema", "Internal"]
                and InfrahubKind.GENERICGROUP not in self._schema.inherit_from
            ):
                labels.append(InfrahubKind.NODE)
            return labels

        if isinstance(self._schema, ProfileSchema | TemplateSchema):
            labels = [self.get_kind()] + self._schema.inherit_from
            return labels

        return [self.get_kind()]

    def get_branch_based_on_support_type(self) -> Branch:
        """If the attribute is branch aware, return the Branch object associated with this attribute
        If the attribute is branch agnostic return the Global Branch

        Returns:
            Branch:
        """
        if self._schema.branch == BranchSupportType.AGNOSTIC:
            return registry.get_global_branch()
        return self._branch

    def __repr__(self) -> str:
        v = f"{self.get_kind()}(ID: {str(self.id)})"
        return v if self._existing else f"{v}[NEW]"

    @property
    def has_changelog(self) -> bool:
        return self._node_changelog is not None

    @property
    def node_changelog(self) -> NodeChangelog:
        if self._node_changelog:
            return self._node_changelog

        raise InitializationError("The node has not been saved so no changelog exists")

    @overload
    @classmethod
    async def init(
        cls,
        schema: NodeSchema | ProfileSchema | TemplateSchema | str,
        db: InfrahubDatabase,
        branch: Branch | str | None = ...,
        at: Timestamp | str | None = ...,
    ) -> Self: ...

    @overload
    @classmethod
    async def init(
        cls,
        schema: type[SchemaProtocol],
        db: InfrahubDatabase,
        branch: Branch | str | None = ...,
        at: Timestamp | str | None = ...,
    ) -> SchemaProtocol: ...

    @classmethod
    async def init(
        cls,
        schema: NodeSchema | ProfileSchema | TemplateSchema | str | type[SchemaProtocol],
        db: InfrahubDatabase,
        branch: Branch | str | None = None,
        at: Timestamp | str | None = None,
    ) -> Self | SchemaProtocol:
        attrs: dict[str, Any] = {}

        branch = await registry.get_branch(branch=branch, db=db)

        if isinstance(schema, NodeSchema | ProfileSchema | TemplateSchema):
            attrs["schema"] = schema
        elif isinstance(schema, str):
            # TODO need to raise a proper exception for this, right now it will raise a generic ValueError
            attrs["schema"] = db.schema.get(name=schema, branch=branch)
        elif hasattr(schema, "_is_runtime_protocol") and schema._is_runtime_protocol:
            attrs["schema"] = db.schema.get(name=schema.__name__, branch=branch)
        else:
            raise ValueError(
                f"Invalid schema provided {type(schema)}, expected NodeSchema, ProfileSchema or TemplateSchema"
            )

        attrs["branch"] = branch
        attrs["at"] = Timestamp(at)

        return cls(**attrs)

    async def handle_pool(
        self,
        db: InfrahubDatabase,
        attribute: BaseAttribute,
        allocate_resources: bool = True,
    ) -> None:
        """Evaluate if a resource has been requested from a pool and apply the resource

        This method only works on number pools, currently Integer is the only type that has the from_pool
        within the create code.

        Supports two cases:
        1. Schema-defined NumberPool attributes (kind="NumberPool" with number_pool_id in parameters)
        2. User-specified from_pool (user explicitly passes {"from_pool": {"id": pool_id}} to a Number attribute)
        """
        number_pool_id: str | None = None
        # Templates must use _from_resource_pool relationships, not from_pool
        if isinstance(self._schema, TemplateSchema):
            if attribute.from_pool:
                pool_rel_name = f"{attribute.name}{RESOURCE_POOL_REL_SUFFIX}"
                raise ValidationError(
                    {
                        f"{attribute.name}.from_pool": (
                            f"'from_pool' is not supported on template attributes. Set the '{pool_rel_name}' relationship on this template instead."
                        )
                    }
                )

        # Case 1: Schema-defined NumberPool attribute
        if (
            not number_pool_id
            and attribute.schema.kind == "NumberPool"
            and isinstance(attribute.schema.parameters, NumberPoolParameters)
        ):
            number_pool_id = attribute.schema.parameters.number_pool_id
            if not number_pool_id:
                raise ValidationError(
                    {f"{attribute.name}": f"The pool for {attribute.name} has not been provisioned yet."}
                )
            attribute.from_pool = {"id": number_pool_id}
            attribute.is_default = False
        # Case 2: User-specified from_pool on a regular Number attribute
        elif not number_pool_id and attribute.from_pool:
            number_pool_id = attribute.from_pool.get("id")
            if not number_pool_id:
                raise ValidationError({f"{attribute.name}.from_pool": "No pool ID specified in from_pool."})

        if not number_pool_id:
            # no pool allocation necessary
            return

        try:
            if is_valid_uuid(number_pool_id):
                number_pool = await registry.manager.get_one(
                    db=db, id=number_pool_id, kind=CoreNumberPool, raise_on_error=True
                )
            else:
                results = await registry.manager.query(
                    db=db, schema=InfrahubKind.NUMBERPOOL, filters={"name__value": number_pool_id}
                )
                if not results:
                    raise NodeNotFoundError(node_type=InfrahubKind.NUMBERPOOL, identifier=number_pool_id)
                number_pool = results[0]
        except NodeNotFoundError as exc:
            raise ValidationError(
                {f"{attribute.name}.from_pool": f"The pool requested {attribute.from_pool} was not found."}
            ) from exc

        if not allocate_resources:
            attribute.source = number_pool.id
            return

        if (
            number_pool.node.value in [self._schema.kind] + self._schema.inherit_from
            and number_pool.node_attribute.value == attribute.name
        ):
            try:
                next_free = await number_pool.get_resource(
                    db=db, branch=self._branch, identifier=self.get_id(), attribute=attribute.schema
                )
            except PoolExhaustedError as exc:
                raise ValidationError(
                    {f"{attribute.name}.from_pool": f"The pool {number_pool.node.value} is exhausted."}
                ) from exc

            attribute.value = next_free
            attribute.source = number_pool.id
        else:
            raise ValidationError(
                {
                    f"{attribute.name}.from_pool": f"The {number_pool.name.value} pool can't be used for '{attribute.name}'."
                }
            )

    async def handle_object_template(
        self, fields: dict, db: InfrahubDatabase, errors: list, process_pools: bool = True
    ) -> None:
        """Fill the `fields` parameters with values from an object template if one is in use."""
        object_template_field = fields.get(OBJECT_TEMPLATE_RELATIONSHIP_NAME)
        if not object_template_field:
            return

        try:
            template: CoreObjectTemplate = await registry.manager.find_object(
                db=db,
                kind=self._schema.get_relationship(name=OBJECT_TEMPLATE_RELATIONSHIP_NAME).peer,
                id=object_template_field.get("id"),
                hfid=object_template_field.get("hfid"),
                branch=self.get_branch_based_on_support_type(),
            )
        except NodeNotFoundError:
            errors.append(
                ValidationError(
                    {
                        f"{OBJECT_TEMPLATE_RELATIONSHIP_NAME}": (
                            "Unable to find the object template in the database "
                            f"'{object_template_field.get('id') or object_template_field.get('hfid')}'"
                        )
                    }
                )
            )
            return

        pool_allocator = DefaultPoolAllocator(db=db, branch=self._branch) if process_pools else NoOpPoolAllocator()
        applier = NodeTemplateApplier(db=db, branch=self._branch, pool_allocator=pool_allocator)
        applied_fields = await applier.apply(
            template=template, target_schema=self._schema, target_id=self.id, user_fields=fields
        )

        # Update fields dict in-place with applied values
        # Only add new keys, don't overwrite existing ones (user fields take precedence)
        for key, value in applied_fields.items():
            if key not in fields:
                fields[key] = value

    async def _get_profile_provided_mandatory_fields(
        self, db: InfrahubDatabase, fields: dict[str, Any]
    ) -> tuple[set[str], set[str]]:
        if not isinstance(self._schema, NodeSchema) or "profiles" not in fields:
            return set(), set()

        mandatory_attrs_to_check = [a for a in self._schema.mandatory_attribute_names if a not in fields.keys()]
        mandatory_rels_to_check = [r for r in self._schema.mandatory_relationship_names if r not in fields.keys()]
        if not mandatory_attrs_to_check and not mandatory_rels_to_check:
            return set(), set()

        profiles_mandatory_field_getter = ProfilesMandatoryFieldGetter(db=db, branch=self._branch)
        return await profiles_mandatory_field_getter.get_mandatory_fields_from_profiles(
            schema=self._schema,
            profiles_data=fields.get("profiles"),
            mandatory_attr_names=mandatory_attrs_to_check,
            mandatory_rel_names=mandatory_rels_to_check,
        )

    async def _process_fields(self, fields: dict, db: InfrahubDatabase, process_pools: bool = True) -> None:
        errors = []

        if "_source" in fields.keys():
            self._source = fields["_source"]
        if "_owner" in fields.keys():
            self._owner = fields["_owner"]

        # -------------------------------------------
        # Validate Input
        # -------------------------------------------
        if "updated_at" in fields and "updated_at" not in self._schema.valid_input_names:
            # FIXME: Allow users to use "updated_at" named attributes until we have proper metadata handling
            fields.pop("updated_at")
        for field_name in fields.keys():
            if field_name not in self._schema.valid_input_names:
                log.error(f"{field_name} is not a valid input for {self.get_kind()}")

        # Backfill fields with the ones from the template if there's one
        await self.handle_object_template(fields=fields, db=db, errors=errors, process_pools=process_pools)

        if not self._existing:
            (
                self._profile_provided_attrs,
                self._profile_provided_rels,
            ) = await self._get_profile_provided_mandatory_fields(db=db, fields=fields)

            for mandatory_attr in self._schema.mandatory_attribute_names:
                if mandatory_attr not in fields.keys() and mandatory_attr not in self._profile_provided_attrs:
                    if self._schema.is_node_schema:
                        mandatory_attribute = self._schema.get_attribute(name=mandatory_attr)
                        if (
                            mandatory_attribute.computed_attribute
                            and mandatory_attribute.computed_attribute.kind == ComputedAttributeKind.JINJA2
                        ):
                            self._computed_jinja2_attributes.append(mandatory_attr)
                            continue

                        if mandatory_attribute.kind == "NumberPool":
                            continue

                    errors.append(
                        ValidationError({mandatory_attr: f"{mandatory_attr} is mandatory for {self.get_kind()}"})
                    )

            for mandatory_rel in self._schema.mandatory_relationship_names:
                if mandatory_rel not in fields.keys() and mandatory_rel not in self._profile_provided_rels:
                    errors.append(
                        ValidationError({mandatory_rel: f"{mandatory_rel} is mandatory for {self.get_kind()}"})
                    )

        if errors:
            raise ValidationError(errors)

        # -------------------------------------------
        # Generate Attribute and Relationship and assign them
        # -------------------------------------------
        errors.extend(await self._process_fields_relationships(fields=fields, db=db))
        errors.extend(await self._process_fields_attributes(fields=fields, db=db, process_pools=process_pools))

        if errors:
            raise ValidationError(errors)

        # Check if any post processor have been defined
        # A processor can be used for example to assigne a default value
        for name in self._attributes + self._relationships:
            if hasattr(self, f"process_{name}"):
                await getattr(self, f"process_{name}")(db=db)

    async def _process_fields_relationships(self, fields: dict, db: InfrahubDatabase) -> list[ValidationError]:
        errors: list[ValidationError] = []

        for rel_schema in self._schema.relationships:
            self._relationships.append(rel_schema.name)

            # Check if there is a more specific generator present
            # Otherwise use the default generator
            generator_method_name = "_generate_relationship_default"
            if hasattr(self, f"generate_{rel_schema.name}"):
                generator_method_name = f"generate_{rel_schema.name}"

            generator_method = getattr(self, generator_method_name)
            try:
                setattr(
                    self,
                    rel_schema.name,
                    await generator_method(
                        db=db, name=rel_schema.name, schema=rel_schema, data=fields.get(rel_schema.name)
                    ),
                )
            except ValidationError as exc:
                errors.append(exc)

        return errors

    async def _process_fields_attributes(
        self, fields: dict, db: InfrahubDatabase, process_pools: bool
    ) -> list[ValidationError]:
        errors: list[ValidationError] = []

        for attr_schema in self._schema.attributes:
            self._attributes.append(attr_schema.name)
            if not self._existing and attr_schema.name in self._computed_jinja2_attributes:
                continue

            # Check if there is a more specific generator present
            # Otherwise use the default generator
            generator_method_name = "_generate_attribute_default"
            if hasattr(self, f"generate_{attr_schema.name}"):
                generator_method_name = f"generate_{attr_schema.name}"

            generator_method = getattr(self, generator_method_name)
            try:
                setattr(
                    self,
                    attr_schema.name,
                    await generator_method(
                        db=db, name=attr_schema.name, schema=attr_schema, data=fields.get(attr_schema.name)
                    ),
                )
                if not self._existing:
                    attribute: BaseAttribute = getattr(self, attr_schema.name)
                    await self.handle_pool(db=db, attribute=attribute, allocate_resources=process_pools)

                    if attr_schema.name in self._profile_provided_attrs:
                        continue

                    if process_pools or attribute.from_pool is None:
                        attribute.validate(value=attribute.value, name=attribute.name, schema=attribute.schema)
            except ValidationError as exc:
                errors.append(exc)

        return errors

    async def _process_macros(self, db: InfrahubDatabase) -> None:
        schema_branch = db.schema.get_schema_branch(self._branch.name)
        allowed_path_types = (
            SchemaElementPathType.ATTR_WITH_PROP
            | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP
            | SchemaElementPathType.REL_ONE_OPTIONAL_ATTR_WITH_PROP
        )
        errors = []
        for macro in self._computed_jinja2_attributes:
            variables = {}
            attr_schema = self._schema.get_attribute(name=macro)
            if not attr_schema.computed_attribute:
                errors.append(
                    ValidationError({macro: f"{macro} is missing computational_logic for macro ({attr_schema.kind})"})
                )
                continue
            if not attr_schema.computed_attribute.jinja2_template:
                errors.append(
                    ValidationError({macro: f"{macro} is missing computational_logic for macro ({attr_schema.kind})"})
                )
                continue

            jinja_template = InfrahubJinja2Template(template=attr_schema.computed_attribute.jinja2_template)
            for variable in jinja_template.get_variables():
                attribute_path = schema_branch.validate_schema_path(
                    node_schema=self._schema, path=variable, allowed_path_types=allowed_path_types
                )
                if attribute_path.is_type_relationship:
                    relationship_attribute: RelationshipManager = getattr(
                        self, attribute_path.active_relationship_schema.name
                    )
                    if peer := await relationship_attribute.get_peer(db=db, raise_on_error=False):
                        related_node = await registry.manager.get_one_by_id_or_default_filter(
                            db=db,
                            id=peer.id,
                            kind=attribute_path.active_relationship_schema.peer,
                            branch=self._branch.name,
                        )

                        attribute: BaseAttribute = getattr(
                            getattr(related_node, attribute_path.active_attribute_schema.name),
                            attribute_path.active_attribute_property_name,
                        )
                        variables[variable] = attribute
                    else:
                        variables[variable] = None

                elif attribute_path.is_type_attribute:
                    attribute = getattr(
                        getattr(self, attribute_path.active_attribute_schema.name),
                        attribute_path.active_attribute_property_name,
                    )
                    variables[variable] = attribute

            content = await jinja_template.render(variables=variables)

            generator_method_name = "_generate_attribute_default"
            if hasattr(self, f"generate_{attr_schema.name}"):
                generator_method_name = f"generate_{attr_schema.name}"

            generator_method = getattr(self, generator_method_name)
            try:
                setattr(
                    self,
                    attr_schema.name,
                    await generator_method(db=db, name=attr_schema.name, schema=attr_schema, data=content),
                )
                attribute = getattr(self, attr_schema.name)

                attribute.validate(value=attribute.value, name=attribute.name, schema=attribute.schema)
            except ValidationError as exc:
                errors.append(exc)

        if errors:
            raise ValidationError(errors)

    async def _generate_relationship_default(
        self,
        name: str,  # noqa: ARG002
        schema: RelationshipSchema,
        data: Any,
        db: InfrahubDatabase,
    ) -> RelationshipManager:
        rm = await RelationshipManager.init(
            db=db,
            data=data,
            schema=schema,
            branch=self._branch,
            at=self._at,
            node=self,
        )

        return rm

    async def _generate_attribute_default(
        self,
        name: str,
        schema: AttributeSchema,
        data: Any,
        db: InfrahubDatabase,  # noqa: ARG002
    ) -> BaseAttribute:
        attr_class = ATTRIBUTE_TYPES[schema.kind].get_infrahub_class()
        attr = attr_class(
            data=data,
            name=name,
            schema=schema,
            branch=self._branch,
            at=self._at,
            node=self,
            source=self._source,
            owner=self._owner,
        )
        if isinstance(data, dict):
            attr._set_created_at(data.pop("created_at", None))
            attr._set_created_by(data.pop("created_by", None))
            attr._set_updated_at(data.pop("updated_at", None))
            attr._set_updated_by(data.pop("updated_by", None))

        return attr

    async def process_label(self, db: InfrahubDatabase | None = None) -> None:  # noqa: ARG002
        # If there label and name are both defined for this node
        #  if label is not define, we'll automatically populate it with a human friendy vesion of name
        if not self._existing and hasattr(self, "label") and hasattr(self, "name"):
            if self.label.value is None and self.name.value:
                self.label.value = " ".join([word.title() for word in self.name.value.split("_")])
                self.label.is_default = False

    async def new(self, db: InfrahubDatabase, id: str | None = None, process_pools: bool = True, **kwargs: Any) -> Self:
        if id and not is_valid_uuid(id):
            raise ValidationError({"id": f"{id} is not a valid UUID"})
        if id:
            query = await NodeCheckIDQuery.init(db=db, node_id=id)
            if await query.count(db=db):
                raise ValidationError({"id": f"{id} is already in use"})

        self.id = id or str(UUIDT())

        await self._process_fields(db=db, fields=kwargs, process_pools=process_pools)
        await self._process_macros(db=db)

        return self

    @staticmethod
    def _merge_relationship_fields(
        definitions: list[HFIDDefinition | TemplateLabel],
    ) -> dict[str, set[str]]:
        """Merge ``relationship_fields`` from the given definitions into a single mapping."""
        extra_filters: dict[str, set[str]] = {}
        for definition in definitions:
            for rel_name, attrs in definition.relationship_fields.items():
                extra_filters.setdefault(rel_name, set()).update(attrs)
        return extra_filters

    def _collect_extra_filters(self, schema_branch: SchemaBranch) -> dict[str, set[str]]:
        """Collect peer attributes that must be loaded during relationship resolution."""
        definitions: list[HFIDDefinition | TemplateLabel] = []

        # If we are creating a new node, we need to resolve extra filters from Display Labels or HFIDs, if we don't do
        # this the fields might be blank.
        # We could also need it when we need to recompute the Display Labels or HFIDs
        if (not self._existing) or self._human_friendly_id:
            if hfid := schema_branch.hfids.get_template_nodes().get(self._schema.kind):
                definitions.append(hfid)
        if (not self._existing) or self._display_label:
            if display_labels := schema_branch.display_labels.get_template_nodes().get(self._schema.kind):
                definitions.append(display_labels)

        return self._merge_relationship_fields(definitions)

    async def resolve_relationships(self, db: InfrahubDatabase, user_id: str = SYSTEM_USER_ID) -> None:
        schema_branch = db.schema.get_schema_branch(name=self.get_branch_based_on_support_type().name)
        extra_filters = self._collect_extra_filters(schema_branch=schema_branch)

        for name in self._relationships:
            relm: RelationshipManager = getattr(self, name)
            query_filter = []
            if name in extra_filters:
                query_filter.extend(list(extra_filters[name]))

            await relm.resolve(db=db, fields=query_filter, user_id=user_id)

    async def load(
        self,
        db: InfrahubDatabase,
        id: str | None = None,
        db_id: str | None = None,
        **kwargs: Any,
    ) -> Self:
        self.id = id
        self.db_id = db_id
        self._existing = True

        if not self._schema.is_schema_node:
            if hfid := kwargs.pop("human_friendly_id", None):
                self._human_friendly_id = HumanFriendlyIdentifier(
                    node_schema=self._schema, template=self._schema.human_friendly_id, value=hfid
                )
            if display_label := kwargs.pop("display_label", None):
                self._display_label = DisplayLabel(
                    node_schema=self._schema, template=self._schema.display_label, value=display_label
                )

        await self._process_fields(db=db, fields=kwargs)
        return self

    async def _create(self, db: InfrahubDatabase, user_id: str, at: Timestamp | None = None) -> NodeChangelog:
        create_at = Timestamp(at)
        self._set_created_at(create_at)
        self._set_created_by(user_id)
        self._set_updated_at(create_at)
        self._set_updated_by(user_id)

        if not self._schema.is_schema_node:
            await self.add_human_friendly_id(db=db)
            await self.add_display_label(db=db)

        query = await NodeCreateAllQuery.init(db=db, node=self, at=create_at, user_id=user_id)
        await query.execute(db=db)

        _, self.db_id = query.get_self_ids()
        self._at = create_at
        self._existing = True

        new_ids = query.get_ids()
        node_changelog = NodeChangelog(node_id=self.get_id(), node_kind=self.get_kind(), display_label="")

        if self._human_friendly_id:
            node_changelog.create_attribute(
                attribute=self._human_friendly_id.get_node_attribute(node=self, at=create_at)
            )
        if self._display_label:
            node_changelog.create_attribute(attribute=self._display_label.get_node_attribute(node=self, at=create_at))

        # Go over the list of Attribute and assign the new IDs one by one
        for name in self._attributes:
            attr: BaseAttribute = getattr(self, name)
            attr.id, attr.db_id = new_ids[name]
            attr.at = create_at
            attr._set_created_at(create_at)
            attr._set_created_by(user_id)
            attr._set_updated_at(create_at)
            attr._set_updated_by(user_id)
            node_changelog.create_attribute(attribute=attr)

        # Go over the list of relationships and assign the new IDs one by one
        for name in self._relationships:
            relm: RelationshipManager = getattr(self, name)
            for rel in relm._relationships:
                identifier = f"{rel.schema.identifier}::{rel.peer_id}"
                rel.id, rel.db_id = new_ids[identifier]
                node_changelog.create_relationship(relationship=rel)

        node_changelog.display_label = await self.get_display_label(db=db)
        return node_changelog

    async def _update(
        self, db: InfrahubDatabase, user_id: str, at: Timestamp | None = None, fields: list[str] | None = None
    ) -> NodeChangelog:
        """Update the node in the database if needed."""

        update_at = Timestamp(at)
        node_changelog = NodeChangelog(node_id=self.get_id(), node_kind=self.get_kind(), display_label="")

        # Go over the list of Attribute and update them one by one
        for name in self._attributes:
            if (fields and name in fields) or not fields:
                attr = self.get_attribute(name=name)
                updated_attribute = await attr.save(db=db, user_id=user_id, at=update_at)
                if updated_attribute:
                    node_changelog.add_attribute(attribute=updated_attribute)

        # Go over the list of relationships and update them one by one
        processed_relationships: list[str] = []
        for name in self._relationships:
            if (fields and name in fields) or not fields:
                processed_relationships.append(name)
                rel = self.get_relationship(name=name)
                updated_relationship = await rel.save(db=db, user_id=user_id, at=update_at)
                node_changelog.add_relationship(relationship_changelog=updated_relationship)

        if len(processed_relationships) != len(self._relationships):
            # Analyze if the node has a parent and add it to the changelog if missing
            if parent_relationship := self._get_parent_relationship_name():
                if parent_relationship not in processed_relationships:
                    rel = self.get_relationship(name=parent_relationship)
                    if parent := await rel.get_parent(db=db):
                        node_changelog.add_parent_from_relationship(parent=parent)

        # Update the HFID if one of its variables is being updated
        if self._human_friendly_id and (
            (fields and "human_friendly_id" in fields) or self._human_friendly_id.needs_update(fields=fields)
        ):
            await self._human_friendly_id.compute(db=db, node=self)
            updated_attribute = await self._human_friendly_id.get_node_attribute(node=self, at=update_at).save(
                at=update_at, db=db
            )
            if updated_attribute:
                node_changelog.add_attribute(attribute=updated_attribute)

        # Update the display label if one of its variables is being updated
        if self._display_label and (
            (fields and "display_label" in fields) or self._display_label.needs_update(fields=fields)
        ):
            await self._display_label.compute(db=db, node=self)
            self._display_label.get_node_attribute(node=self, at=update_at).get_create_data(node_schema=self._schema)
            updated_attribute = await self._display_label.get_node_attribute(node=self, at=update_at).save(
                at=update_at, db=db
            )
            if updated_attribute:
                node_changelog.add_attribute(attribute=updated_attribute)

        node_changelog.display_label = await self.get_display_label(db=db)

        if node_changelog.has_changes:
            self._set_updated_at(update_at)
            self._set_updated_by(user_id)
            update_branch = self.get_branch_based_on_support_type()
            if update_branch.is_default or update_branch.is_global:
                await self._save_metadata(db=db, branch=update_branch)
        return node_changelog

    async def save(
        self,
        db: InfrahubDatabase,
        user_id: str = SYSTEM_USER_ID,
        at: Timestamp | None = None,
        fields: list[str] | None = None,
    ) -> Self:
        """Create or Update the Node in the database."""
        save_at = Timestamp(at)

        if self._existing:
            self._node_changelog = await self._update(db=db, user_id=user_id, at=save_at, fields=fields)
        else:
            self._node_changelog = await self._create(db=db, user_id=user_id, at=save_at)

        return self

    async def _save_metadata(self, db: InfrahubDatabase, branch: Branch) -> None:
        if user_id := self._get_updated_by():
            update_metadata_query = await NodeUpdateMetadataQuery.init(
                db=db, branch=branch, node_id=self.get_id(), user_id=user_id, at=self._get_updated_at()
            )
            await update_metadata_query.execute(db=db)

    async def delete(self, db: InfrahubDatabase, user_id: str = SYSTEM_USER_ID, at: Timestamp | None = None) -> None:
        """Delete the Node in the database."""

        delete_at = Timestamp(at)

        node_changelog = NodeChangelog(
            node_id=self.get_id(), node_kind=self.get_kind(), display_label=await self.get_display_label(db=db)
        )
        # Go over the list of Attribute and update them one by one
        for name in self._attributes:
            attr: BaseAttribute = getattr(self, name)
            if deleted_attribute := await attr.delete(db=db, at=delete_at, user_id=user_id):
                node_changelog.add_attribute(attribute=deleted_attribute)

        if self._human_friendly_id:
            if deleted_attribute := await self._human_friendly_id.get_node_attribute(node=self, at=delete_at).delete(
                db=db,
                user_id=user_id,
                at=delete_at,
            ):
                node_changelog.add_attribute(attribute=deleted_attribute)

        if self._display_label:
            if deleted_attribute := await self._display_label.get_node_attribute(node=self, at=delete_at).delete(
                db=db, at=delete_at, user_id=user_id
            ):
                node_changelog.add_attribute(attribute=deleted_attribute)

        branch = self.get_branch_based_on_support_type()

        delete_query = await RelationshipDeleteAllQuery.init(
            db=db,
            node_id=self.get_id(),
            branch=branch,
            user_id=user_id,
            at=delete_at,
            branch_agnostic=branch.name == GLOBAL_BRANCH_NAME,
        )
        await delete_query.execute(db=db)

        deleted_relationships_changelogs = delete_query.get_deleted_relationships_changelog(self._schema)
        for relationship_changelog in deleted_relationships_changelogs:
            node_changelog.add_relationship(relationship_changelog=relationship_changelog)

        query = await NodeDeleteQuery.init(db=db, node=self, at=delete_at, user_id=user_id)
        await query.execute(db=db)

        self._node_changelog = node_changelog

    async def to_graphql(
        self,
        db: InfrahubDatabase,
        fields: dict | None = None,
        related_node_ids: set | None = None,
        filter_sensitive: bool = False,
        permissions: dict | None = None,
        include_properties: bool = True,
    ) -> dict:
        """Generate GraphQL Payload for all attributes

        Returns:
            (dict): Return GraphQL Payload
        """

        response: dict[str, Any] = {"id": self.id, KIND_GRAPHQL_FIELD_NAME: self.get_kind()}

        if related_node_ids is not None:
            related_node_ids.add(self.id)

        FIELD_NAME_TO_EXCLUDE = ["id"] + self._schema.relationship_names

        field_names = (
            [field_name for field_name in fields.keys() if field_name not in FIELD_NAME_TO_EXCLUDE]
            if fields and isinstance(fields, dict)
            else self._schema.attribute_names + ["__typename", "display_label"]
        )

        for field_name in field_names:
            if field_name == "__typename":
                # Note we already store kind within KIND_GRAPHQL_FIELD_NAME.
                response[field_name] = self.get_kind()
                continue

            if field_name == "display_label":
                response[field_name] = await self.get_display_label(db=db)
                continue

            if field_name == "hfid":
                response[field_name] = await self.get_hfid(db=db)
                continue

            if field_name == "_updated_at":
                if updated_at := self._get_updated_at():
                    response[field_name] = await updated_at.to_graphql()
                else:
                    response[field_name] = None
                continue

            field: BaseAttribute | None = getattr(self, field_name, None)

            if not field:
                response[field_name] = None
                continue

            if fields and isinstance(fields, dict):
                response[field_name] = await field.to_graphql(
                    db=db,
                    fields=fields.get(field_name),
                    related_node_ids=related_node_ids,
                    filter_sensitive=filter_sensitive,
                    permissions=permissions,
                    include_properties=include_properties,
                )
            else:
                response[field_name] = await field.to_graphql(
                    db=db,
                    filter_sensitive=filter_sensitive,
                    permissions=permissions,
                    include_properties=include_properties,
                )

        for relationship_schema in self.get_schema().relationships:
            peer_rels = []
            if not fields or relationship_schema.name not in fields:
                continue
            rel_manager = getattr(self, relationship_schema.name, None)
            if rel_manager is None:
                continue
            try:
                if relationship_schema.cardinality is RelationshipCardinality.ONE:
                    rel = rel_manager.get_one()
                    if rel:
                        peer_rels = [rel]
                else:
                    peer_rels = list(rel_manager)
                if peer_rels:
                    response[relationship_schema.name] = [
                        {"node": {"id": relationship.peer_id}} for relationship in peer_rels if relationship.peer_id
                    ]
            except LookupError:
                continue

        return response

    async def _build_meta_response(self, field_name: str, fields: dict) -> dict:
        data = {}
        for meta_field in fields.get(field_name, {}).keys():
            if meta_field == "created_at":
                created_at = self._get_created_at()
                data["created_at"] = created_at.to_datetime() if created_at else None

            if meta_field == "created_by":
                data["created_by"] = (
                    {"id": self._get_created_by(), "__kind__": "CoreAccount"} if self._get_created_by() else None
                )

            if meta_field == "updated_by":
                data["updated_by"] = (
                    {"id": self._get_updated_by(), "__kind__": "CoreAccount"} if self._get_updated_by() else None
                )

            if meta_field == "updated_at":
                updated_at = self._get_updated_at()
                data["updated_at"] = updated_at.to_datetime() if updated_at else None
        return data

    async def from_graphql(self, data: dict, db: InfrahubDatabase, process_pools: bool = True) -> bool:
        """Update object from a GraphQL payload."""

        changed = False

        for key, value in data.items():
            if key in self._attributes and isinstance(value, dict):
                attribute = getattr(self, key)
                changed |= await attribute.from_graphql(data=value, db=db, process_pools=process_pools)

            if key in self._relationships:
                rel: RelationshipManager = getattr(self, key)
                changed |= await rel.update(db=db, data=value, process_delete=process_pools)

        return changed

    async def render_display_label(self, db: InfrahubDatabase | None = None) -> str:  # noqa: ARG002
        if not self._schema.display_labels:
            return repr(self)

        display_elements = []
        for item in self._schema.display_labels:
            item_elements = item.split("__")
            if len(item_elements) != 2:
                raise ValidationError("Display Label can only have one level")

            if item_elements[0] not in self._schema.attribute_names:
                raise ValidationError("Only Attribute can be used in Display Label")

            attr = getattr(self, item_elements[0])
            attr_value = getattr(attr, item_elements[1])
            if isinstance(attr_value, Enum):
                display_elements.append(attr_value.value)
            else:
                display_elements.append(attr_value)

        if not display_elements or all(de is None for de in display_elements):
            return ""
        display_label = " ".join([str(de) for de in display_elements])
        if not display_label.strip():
            return repr(self)
        return display_label.strip()

    async def set_human_friendly_id(self, value: list[str] | None) -> None:
        """Set the human friendly ID of this node if one is set. `save()` must be called to commit the change in the database."""
        if self._human_friendly_id is None:
            return

        self._human_friendly_id.set_value(value=value, manually_assigned=True)

    async def set_display_label(self, value: str | None) -> None:
        """Set the display label of this node if one is set. `save()` must be called to commit the change in the database."""
        if self._display_label is None:
            return

        self._display_label.set_value(value=value, manually_assigned=True)

    def _get_parent_relationship_name(self) -> str | None:
        """Return the name of the parent relationship is one is present"""
        for relationship in self._schema.relationships:
            if relationship.kind == RelationshipKind.PARENT:
                return relationship.name

        return None

    async def get_object_template(self, db: InfrahubDatabase) -> CoreObjectTemplate | None:
        object_template: RelationshipManager | None = getattr(self, OBJECT_TEMPLATE_RELATIONSHIP_NAME, None)
        return (
            await object_template.get_peer(db=db, peer_type=CoreObjectTemplate) if object_template is not None else None
        )

    def get_relationships(
        self, kind: RelationshipKind, exclude: Sequence[str] | None = None
    ) -> list[RelationshipSchema]:
        """Return relationships of a given kind with the possiblity to exclude some of them by name."""
        if exclude is None:
            exclude = []

        return [
            relationship
            for relationship in self.get_schema().relationships
            if relationship.name not in exclude and relationship.kind == kind
        ]

    def validate_relationships(self) -> None:
        for name in self._relationships:
            relm: RelationshipManager = getattr(self, name)
            relm.validate()

    async def get_parent_relationship_peer(self, db: InfrahubDatabase, name: str) -> Node | None:
        """When a node has a parent relationship of a given name, this method returns the peer of that relationship."""
        relationship = self.get_schema().get_relationship(name=name)
        if relationship.kind != RelationshipKind.PARENT:
            raise ValueError(f"Relationship '{name}' is not of kind 'parent'")

        relm: RelationshipManager = getattr(self, name)
        return await relm.get_peer(db=db)
