###############################################################################
#
# (C) Copyright 2025 EVERYSK TECHNOLOGIES
#
# This is an unpublished work containing confidential and proprietary
# information of EVERYSK TECHNOLOGIES. Disclosure, use, or reproduction
# without authorization of EVERYSK TECHNOLOGIES is prohibited.
#
###############################################################################
from typing import Any, TypeVar

from everysk.core.mapping.metaclass import BaseMappingMetaClass

FT = TypeVar('FT')
MT = TypeVar('MT', bound='BaseMapping')


class BaseMapping(dict, metaclass=BaseMappingMetaClass):
    ## Internal attributes
    __attributes__: set[str] = None
    # To track deleted keys and raise AttributeError on access
    __deleted_keys__: set[str] = None
    # Used to not create certain keys in the instance dict
    __invalid_keys__: frozenset[str] = None
    # Used to enable/disable field validation on set
    __validate_fields__: bool = False

    ## Internal methods
    def __init__(self, **kwargs: Any) -> None:
        # We always need to initialize the deleted attribute in the instance
        self.__deleted_keys__ = set()

        # We need to set all attributes defined in the class and the
        # ones passed as kwargs in the instance so they appear in the
        # dict representation of the instance if they are valid attributes
        keys = self.__class__.__attributes__ | kwargs.keys()
        for key in keys:
            # Don't use the getattr as the second argument because
            # it will be executed even if the key is in kwargs
            value = kwargs.get(key, Undefined)
            if value is Undefined:
                value = getattr(self.__class__, key)

            # We only set attributes that are defined in the class
            if key in self.__attributes__:
                setattr(self, key, value)
            else:
                self[key] = value

    def __delitem__(self, key: str) -> None:
        """
        Delete the item from the instance dict.
        Updates the deleted keys set to track removed keys.

        Args:
            key (str): The key to delete.
        """
        self.__deleted_keys__.add(key)
        return super().__delitem__(key)

    def __getattr__(self, name: str) -> Any:
        """
        Get the attribute from the instance dict.
        When an attribute is not found in the instance, this method is called.

        Args:
            name (str): The name of the attribute to get.

        Raises:
            AttributeError: If the attribute is not found in the instance.
        """
        if name in self:
            return self[name]

        msg = f"'{self.__class__.__name__}' object has no attribute '{name}'."
        raise AttributeError(msg)

    def __setattr__(self, name: str, value: Any) -> None:
        """
        Set the attribute on the instance.
        Only attributes defined in the class are set as attributes the rest go to the dict
        and will be accessible via key access or via __getattr__.

        Args:
            name (str): The name of the attribute to set.
            value (Any): The value to set for the attribute.
        """
        # We only set attributes in the object that are already defined
        if hasattr(self, name):
            super().__setattr__(name, value)
        else:
            # Otherwise, everything goes to the dict
            self[name] = value

    def __setitem__(self, key: str, value: Any) -> None:
        """
        Set the value for the given key in the instance dict.
        Updates the changed set to track modified keys.

        Args:
            key (str): The key to set the value for.
            value (Any): The value to set for the key.
        """
        if self.__validate_fields__:
            cls = type(self)
            field = cls.__dict__.get(key)
            if field and hasattr(field, 'validate'):
                field.validate(value)

        super().__setitem__(key, value)

    ## Private methods
    @classmethod
    def _generate_attributes(cls) -> None:
        """
        Generate the set of attributes for the class by collecting attributes
        from the class and its base classes up to BaseMapping (inclusive).
        """
        for base in cls.mro()[:-2]:
            keys = getattr(base, '__attributes__', set())
            if keys:
                cls.__attributes__.update(keys)
                # If the base has the attributes, we don't need to get the others
                # because they were already collected in the previous bases
                break

            # If we don't have the attributes in the base we make it from the annotations
            keys = {key for key in base.__annotations__ if not key.startswith('__')}
            cls.__attributes__.update(keys)

    def _is_valid_key(self, key: str) -> bool:
        """
        Check if the given key is valid to be used.

        Args:
            key (str): The key to check.
        """
        if self.__invalid_keys__:
            return key not in self.__invalid_keys__

        return True

    def _to_dict_excluded_keys(self) -> set[str]:
        """
        Returns a set of keys to be excluded when converting the object to a dictionary.
        This method can be overridden in subclasses to specify custom keys to exclude.
        """
        return set()

    ## Public methods
    @classmethod
    def get_full_dotted_class_path(cls) -> str:
        """
        Return full dotted class path to be used on import functions.

        Example:
            'everysk.core.BaseObject'
        """
        return f'{cls.__module__}.{cls.__name__}'

    def to_native(self, *, add_class_path: str | None = None, recursion: bool = False) -> Any:
        """
        Converts the object to the specified Python type.
        This method is used inside the serialization process to convert the object to a native Python type.

        Args:
            add_class_path (str | None, optional): The class path to add when converting the object. Defaults to None.
            recursion (bool, optional): Indicates whether to recursively convert nested objects. Defaults to False.

        Returns:
            object: The converted object.

        """
        return self.to_dict(add_class_path=add_class_path, recursion=recursion)

    def to_dict(self, *, add_class_path: bool = False, recursion: bool = False) -> dict:
        """
        Legacy method used to convert the object to a dictionary.
        If add_class_path is True, the full doted class path will be added to the dictionary.
        If recursion is True, the method will call the to_dict method of the child objects.

        Args:
            add_class_path (bool, optional): Flag to add the class path in the result. Defaults to False.
            recursion (bool, optional): Flag to transform the children too. Defaults to False.
        """
        excluded_keys = self._to_dict_excluded_keys()
        # If no special processing is needed, get a shallow copy of the object
        if not recursion and not excluded_keys:
            dct = self.copy()

        # If there are excluded keys but no recursion, filter them out
        elif not recursion:
            dct = {key: value for key, value in self.items() if key not in excluded_keys}

        # If recursion is needed, process each field accordingly filtering excluded keys
        else:
            # Import here to avoid circular imports because BaseObject also imports BaseMapping
            # to transform objects to dict in the same way
            from everysk.core.object import BaseObject  # noqa: PLC0415

            dct = {}
            for key, value in self.items():
                if key in excluded_keys:
                    continue

                if not isinstance(value, (BaseMapping, BaseObject)):
                    dct[key] = value
                else:
                    dct[key] = value.to_dict(add_class_path=add_class_path, recursion=recursion)

        if add_class_path:
            dct['__class_path__'] = self.get_full_dotted_class_path()

        return dct
