from __future__ import annotations

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

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

from infrahub import lock
from infrahub.core import registry
from infrahub.core.changelog.models import NodeChangelog
from infrahub.core.constants import (
    GLOBAL_BRANCH_NAME,
    OBJECT_TEMPLATE_NAME_ATTR,
    OBJECT_TEMPLATE_RELATIONSHIP_NAME,
    SYSTEM_USER_ID,
    BranchSupportType,
    ComputedAttributeKind,
    InfrahubKind,
    NumberPoolType,
    RelationshipCardinality,
    RelationshipKind,
)
from infrahub.core.constants.schema import 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,
    GenericSchema,
    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.models import NumberPoolLockDefinition
from infrahub.profiles.mandatory_fields_checker import ProfilesMandatoryFieldGetter
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.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

    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)
        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)
        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 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, errors: list, 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.
        """

        if attribute.schema.kind == "NumberPool" and isinstance(attribute.schema.parameters, NumberPoolParameters):
            attribute.from_pool = {"id": attribute.schema.parameters.number_pool_id}
            attribute.is_default = False

        if not attribute.from_pool or not allocate_resources:
            return

        try:
            number_pool = await registry.manager.get_one_by_id_or_default_filter(
                db=db, id=attribute.from_pool["id"], kind=CoreNumberPool
            )
        except NodeNotFoundError:
            if attribute.schema.kind == "NumberPool" and isinstance(attribute.schema.parameters, NumberPoolParameters):
                number_pool = await self.fetch_or_create_number_pool(
                    db=db, schema_node=self._schema, schema_attribute=attribute.schema, branch=self._branch
                )

            else:
                errors.append(
                    ValidationError(
                        {f"{attribute.name}.from_pool": f"The pool requested {attribute.from_pool} was not found."}
                    )
                )
                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, node=self, attribute=attribute.schema
                )
            except PoolExhaustedError:
                errors.append(
                    ValidationError({f"{attribute.name}.from_pool": f"The pool {number_pool.node.value} is exhausted."})
                )
                return

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

    @staticmethod
    async def fetch_or_create_number_pool(
        db: InfrahubDatabase,
        schema_node: NodeSchema | GenericSchema,
        schema_attribute: AttributeSchema,
        branch: Branch | None = None,
    ) -> CoreNumberPool:
        """Fetch or create a number pool based on the schema attribute parameters.

        Warning, ideally this method should be outside of the Node class, but it is itself using the Node class to create the pool node.
        """

        if (
            schema_attribute.kind != "NumberPool"
            or not schema_attribute.parameters
            or not isinstance(schema_attribute.parameters, NumberPoolParameters)
        ):
            raise ValueError("Attribute is not of type NumberPool")

        number_pool_from_db: CoreNumberPool | None = None
        number_pool_parameters: NumberPoolParameters = schema_attribute.parameters

        lock_definition = NumberPoolLockDefinition(pool_id=str(number_pool_parameters.number_pool_id))
        async with lock.registry.get(
            name=lock_definition.lock_name, namespace=lock_definition.namespace_name, local=False
        ):
            try:
                number_pool_from_db = await registry.manager.get_one_by_id_or_default_filter(
                    db=db, id=str(number_pool_parameters.number_pool_id), kind=CoreNumberPool
                )
                return number_pool_from_db  # type: ignore[return-value]

            except NodeNotFoundError:
                schema = db.schema.get_node_schema(name="CoreNumberPool", duplicate=False)

                pool_node = schema_node.kind
                if schema_attribute.inherited:
                    for generic_name in schema_node.inherit_from:
                        generic_node = db.schema.get_generic_schema(name=generic_name, duplicate=False)
                        if schema_attribute.name in generic_node.attribute_names:
                            pool_node = generic_node.kind
                            break

                number_pool = await Node.init(db=db, schema=schema, branch=branch)
                await number_pool.new(
                    db=db,
                    id=number_pool_parameters.number_pool_id,
                    name=f"{pool_node}.{schema_attribute.name} [{number_pool_parameters.number_pool_id}]",
                    node=pool_node,
                    node_attribute=schema_attribute.name,
                    start_range=number_pool_parameters.start_range,
                    end_range=number_pool_parameters.end_range,
                    pool_type=NumberPoolType.SCHEMA.value,
                )
                await number_pool.save(db=db)

                # Do a lookup of the number pool to get the correct mapped type from the registry
                # without this we don't get access to the .get_resource() method.
                return await registry.manager.get_one_by_id_or_default_filter(
                    db=db, id=number_pool.id, kind=CoreNumberPool
                )

    async def handle_object_template(self, fields: dict, db: InfrahubDatabase, errors: list) -> 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

        # Handle attributes, copy values from template
        # Relationships handling in performed in GraphQL mutation to create nodes for relationships
        for attribute_name in template._attributes:
            if attribute_name in list(fields) + [OBJECT_TEMPLATE_NAME_ATTR]:
                continue
            attr = getattr(template, attribute_name)
            attr_value = attr.value
            if attr_value is not None:
                # Preserve is_from_profile flag when copying from template
                field_data = {"value": attr_value, "source": attr.source_id or template.id}
                if attr.is_from_profile:
                    field_data["is_from_profile"] = True
                fields[attribute_name] = field_data

        for relationship_name in template._relationships:
            relationship_schema = template._schema.get_relationship(name=relationship_name)
            if (
                relationship_name in list(fields)
                or relationship_schema.kind
                not in [RelationshipKind.ATTRIBUTE, RelationshipKind.GENERIC, RelationshipKind.PROFILE]
                or relationship_name == OBJECT_TEMPLATE_RELATIONSHIP_NAME
            ):
                continue

            relationship: RelationshipManager = getattr(template, relationship_name)
            if relationship_schema.cardinality == RelationshipCardinality.ONE:
                if relationship_peer := await relationship.get_peer(db=db):
                    fields[relationship_name] = {"id": relationship_peer.id}
            elif relationship_peers := await relationship.get_peers(db=db):
                fields[relationship_name] = [{"id": peer_id} for peer_id in relationship_peers]

    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)

        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, errors=errors, 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 = Jinja2Template(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

    async def resolve_relationships(self, db: InfrahubDatabase) -> None:
        extra_filters: dict[str, set[str]] = {}

        if not self._existing:
            # If we are creating a new node, we need to resolve extra filters from HFID and Display Labels,
            # if we don't do this the fields might be blank
            schema_branch = db.schema.get_schema_branch(name=self.get_branch_based_on_support_type().name)
            try:
                hfid_identifier = schema_branch.hfids.get_node_definition(kind=self._schema.kind)
                for rel_name, attrs in hfid_identifier.relationship_fields.items():
                    extra_filters.setdefault(rel_name, set()).update(attrs)
            except KeyError:
                # No HFID defined for this kind
                ...
            try:
                display_label_identifier = schema_branch.display_labels.get_template_node(kind=self._schema.kind)
                for rel_name, attrs in display_label_identifier.relationship_fields.items():
                    extra_filters.setdefault(rel_name, set()).update(attrs)
            except KeyError:
                # No Display Label defined for this kind
                ...

        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)

    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)
