
import hashlib
import json
from typing import Annotated, Any, Dict, List, Type, cast, get_args, get_origin, get_type_hints
from nc_db.identifiable import Identifiable
from nc_db.metadata.column_mappers.body_mapper import BodyMapper
from nc_db.metadata.column_mappers.column_mapper import ColumnMapper
from nc_db.metadata.db_column import DbColumn
import base64

from nc_db.metadata.pg_utils import to_pg_identifier


class ClassMetadata():
    """
    Contains metadata statically derived through class introspection.
    IMPORTANT: Every property in this class must be deterministically set based upon the cls property passed
    to the constructor. The constructor MUST be idempotent.

    This class extracts metadata from a class type. It does not require any connectivity or
    data from the database.
    """
    @property
    def cls(self) -> Type[Identifiable]:
        """
        The Python class definition
        """
        return self._cls

    @property
    def table_name(self) -> str:
        """
        The table name where instances of this class will be stored in the DB.
        """
        return self._table_name

    @property
    def schema_version(self) -> int:
        """
        The schema version of the class. This is user-defined and should be monotonic.
        MUST be incremented when a class is updated with new / modified / deleted DbColumn attributes.
        """
        return self._schema_version

    @property
    def columns(self) -> List[ColumnMapper]:
        """
        Used to generate b-tree indexes in Postgres
        """
        return self._columns

    @property
    def signature(self) -> str:
        """
        A unique signature for this class based ONLY upon its schema and indexes.
        NOTE: Over time, a class may be modified. Each time this occurs, the unique schema identifier will
        change.
        NOTE: This signature must be deterministic based only upon the structure of the class . It must not
        depend upon any data from the DB or other sources.
        """
        return self._signature

    @property
    def class_name(self):
        """
        The friendly, easy-to-read name of this class.
        :param self: Description
        """
        return self._name

    @property
    def mutable_columns(self) -> List[ColumnMapper]:
        """
        Returns all columns that are not GENERATED ALWAYS by the db
        """
        return [column for column in self.columns if not column.auto_generated]

    @property
    def immutable_columns(self) -> List[ColumnMapper]:
        """
        Returns all columns that are GENERATED ALWAYS by the db
        """
        return [column for column in self.columns if column.auto_generated]

    def __init__(
            self, cls: Type[Identifiable],
            schema_version: int
    ):
        self._cls = cls
        self._name = cls.__name__
        self._schema_version = schema_version
        self._table_name = to_pg_identifier(f"{self._name}_{self._schema_version}")
        self._columns: List[ColumnMapper] = []
        full_hints = get_type_hints(cls, include_extras=True)
        # Add user-defined columns
        for attr_name, type_hint in full_hints.items():
            origin = get_origin(type_hint)
            if origin is Annotated:

                # The first arg is the type -- we already know what this is, so exclude it.
                args = get_args(type_hint)
                indexes = [arg for arg in args if isinstance(arg, DbColumn)]
                index_count = len(indexes)
                if index_count > 1:
                    raise RuntimeError(
                        "Multiple DbColumn instances where annotated on a single class attribute. Only one DbIndex instance is allowed per attribute.")
                if index_count == 1:
                    if attr_name != attr_name.lower():
                        raise RuntimeError("All class attributes that have an index or column specified must be lowercase (e.g. snake_case)")
                    db_index = indexes[0]
                    self.columns.append(ColumnMapper(attr_name, db_index.column_definition, True, db_index.auto_generated, db_index.primary_key))
        # Generate the signature now that we have the user-defined columns available.
        self._signature = self._generate_signature(cls)

        # All classes need a body acolumn. The body column MUST be last to allow the other column mappers to
        # remove their value from the dictionary. This is done to reduce storage and ensure data consistency (no need to store
        # values in the json and their own column)
        self.columns.append(BodyMapper())

    def _generate_signature(self, cls: Type[Identifiable]) -> str:
        """
        Deterministically generates a signature based solely upon name and structure of
        the class.
        """
        schema = cls.model_json_schema()

        def clean_schema(node: Any) -> None:
            """
            Recursively removes metadata that doesn't change the structure of a model.
            This is removed so we can autogenerate a version (hash) to ensure we
            don't mix versions of the same class name.
            Adds a string representing the indexes so new indexes can be added and migrations supported.

            :param node: A JSON node
            """
            keys_to_remove = ['title', 'description', 'example']
            if isinstance(node, dict):
                node = cast(Dict[str, Any], node)
                # Remove metadata keys that don't affect the data contract
                for key in keys_to_remove:
                    if key in node:
                        del node[key]
                for value in list(node.values()):
                    clean_schema(value)
            elif isinstance(node, list):
                node = cast(List[Any], node)
                for item in node:
                    clean_schema(item)
            return node
        clean_schema(schema)

        class_schema = json.dumps(schema, sort_keys=True, default=str, ensure_ascii=True)
        # Add all of the column names into the hash so we get a unique hash if any of the columns or indexes change.
        for column in self.columns:
            class_schema += f"___{column.name}"
        hash = hashlib.sha256(class_schema.encode('utf-8'), usedforsecurity=False)
        # To minimize characers, encode as base 32 and remove padding ('=')
        signature = base64.b32encode(hash.digest()).decode('utf-8').replace("=", "")
        return signature
