"""Implementation of a dictionary based repository"""

import copy
import json
from collections import defaultdict
from datetime import date, datetime
from itertools import count

from threading import Lock
from typing import Any  # type: ignore[reportAssignmentType]
from uuid import UUID

from protean.core.database_model import BaseDatabaseModel
from protean.core.queryset import ResultSet
from protean.exceptions import ObjectNotFoundError
from protean.port.dao import BaseDAO, BaseLookup
from protean.port.provider import BaseProvider, DatabaseCapabilities
from protean.utils import fully_qualified_name
from protean.utils.container import Options
from protean.utils.globals import current_uow
from protean.utils.query import Q
from protean.utils.reflection import fields, id_field


class _ReverseCompare:
    """Helper class to reverse comparison order for descending sorts"""

    def __init__(self, value):
        self.value = value

    def __lt__(self, other):
        if isinstance(other, _ReverseCompare):
            # Both are reverse, so flip the comparison
            return self.value > other.value
        # Comparing with non-reverse value should not happen in our context
        return self.value > other

    def __le__(self, other):
        if isinstance(other, _ReverseCompare):
            return self.value >= other.value
        return self.value >= other

    def __eq__(self, other):
        if isinstance(other, _ReverseCompare):
            return self.value == other.value
        return self.value == other

    def __ne__(self, other):
        return not self.__eq__(other)

    def __gt__(self, other):
        if isinstance(other, _ReverseCompare):
            return self.value < other.value
        return self.value < other

    def __ge__(self, other):
        if isinstance(other, _ReverseCompare):
            return self.value <= other.value
        return self.value <= other

    def __repr__(self):
        return f"_ReverseCompare({self.value!r})"


class MemoryModel(BaseDatabaseModel):
    """A model for the dictionary repository"""

    @classmethod
    def _get_value(cls, item: dict[str, Any], key: str) -> Any:
        return item[key]

    @classmethod
    def from_entity(cls, entity) -> dict[str, Any]:
        """Convert the entity to a dictionary record"""
        return cls._entity_to_dict(entity)


class MemorySession:
    def __init__(self, provider, new_connection=False):
        self._provider = provider
        self.is_active = True

        if (
            current_uow and self._provider.name in current_uow._sessions
        ) and not new_connection:
            self._db = current_uow._sessions[self._provider.name]._db
        else:
            self._db = {
                "data": copy.deepcopy(self._provider._databases),
                "lock": self._provider._locks.setdefault(self._provider.name, Lock()),
                "counters": self._provider._counters,
            }

    def commit(self):
        if current_uow and self._provider.name in current_uow._sessions:
            current_uow._sessions[self._provider.name]._db["data"] = self._db["data"]
        else:
            self._provider._databases = self._db["data"]

    def rollback(self):
        pass

    def close(self):
        pass


class MemoryProvider(BaseProvider):
    """Provider class for Dict Repositories"""

    __database__ = "memory"

    @property
    def capabilities(self) -> DatabaseCapabilities:
        """Memory provider supports basic storage with simulated transactions."""
        return DatabaseCapabilities.IN_MEMORY

    def __init__(self, name, domain, conn_info: dict):
        """Initialize Provider with Connection/Adapter details"""

        # In case of `MemoryProvider`, the `database` value will always be `memory`.
        super().__init__(name, domain, conn_info)

        # Global in-memory store of dict data.
        self._databases = defaultdict(dict)
        self._locks = defaultdict(Lock)
        self._counters = defaultdict(count)

        # A temporary cache of already constructed model classes
        self._database_model_classes = {}

    def get_session(self):
        """Return a session object

        For Dictionary Repo, a session translates to a copy of the
        `database`. All transactions on the Provider's repositories
        are committed on this copy of the database.
        """
        return MemorySession(self)

    def get_connection(self, session_cls=None):
        """Return the dictionary database object"""
        return MemorySession(self, new_connection=True)

    def is_alive(self) -> bool:
        """Check if the connection is alive"""
        return True

    def _data_reset(self):
        """Reset data"""
        self._databases = defaultdict(dict)
        self._locks = defaultdict(Lock)
        self._counters = defaultdict(count)

        # Discard any active Unit of Work
        if current_uow and current_uow.in_progress:
            current_uow.rollback()

    def close(self):
        """Close the provider and clean up resources.

        For MemoryProvider, this is a no-op since there are no persistent
        connections or external resources to clean up.
        """
        pass

    def decorate_database_model_class(self, entity_cls, database_model_cls):
        cache_key = fully_qualified_name(entity_cls)

        # Return the model class if it was already seen/decorated
        if cache_key in self._database_model_classes:
            return self._database_model_classes[cache_key]

        # If `database_model_cls` is already subclassed from MemoryModel,
        #   this method call is a no-op
        if issubclass(database_model_cls, MemoryModel):
            return database_model_cls
        else:
            custom_attrs = {
                key: value
                for (key, value) in vars(database_model_cls).items()
                if key not in ["Meta", "__module__", "__doc__", "__weakref__"]
            }

            meta_ = Options()
            meta_.part_of = entity_cls

            custom_attrs.update({"meta_": meta_})
            # User class is in the MRO; custom methods/properties resolve via standard Python MRO
            decorated_database_database_model_cls = type(
                database_model_cls.__name__,
                (MemoryModel, database_model_cls),
                custom_attrs,
            )

            # Memoize the constructed model class
            self._database_model_classes[cache_key] = (
                decorated_database_database_model_cls
            )

            return decorated_database_database_model_cls

    def construct_database_model_class(self, entity_cls):
        """Return associated, fully-baked Model class"""
        database_model_cls = None
        cache_key = fully_qualified_name(entity_cls)

        # Return the model class if it was already seen/decorated
        if cache_key in self._database_model_classes:
            database_model_cls = self._database_model_classes[cache_key]
        else:
            meta_ = Options()
            meta_.part_of = entity_cls

            attrs = {
                "meta_": meta_,
            }
            # Auto-generated model; no user-defined attributes to carry over
            database_model_cls = type(
                entity_cls.__name__ + "Model", (MemoryModel,), attrs
            )

            # Memoize the constructed model class
            self._database_model_classes[cache_key] = database_model_cls

        # Set Entity Class as a class level attribute for the Model, to be able to reference later.
        return database_model_cls

    def get_dao(self, entity_cls, database_model_cls):
        """Return a DAO object configured with a live connection"""
        return DictDAO(self.domain, self, entity_cls, database_model_cls)

    def _evaluate_lookup(self, key, value, negated, db):
        """Extract values from DB that match the given criteria"""
        results = {}
        for record_key, record_value in db.items():
            match = True

            stripped_key, lookup_class = self._extract_lookup(key)
            lookup = lookup_class(record_value[stripped_key], value)

            if record_value[stripped_key] is not None:
                result = lookup.evaluate()
                match &= not result if negated else result
            else:
                match = False

            if match:
                results[record_key] = record_value

        return results

    def _raw(self, query: Any, data: Any = None) -> list:
        """Run raw queries on the memory database.

        As an example of running ``raw`` queries on a Dict repository, we will run the query
        on all possible schemas, and return all results.

        For this stand-in repository, the query string is a json string that contains kwargs
        criteria with straight-forward equality checks. Individual criteria are always AND-ed
        and the result is always a subset of the full repository.

        We will ignore the `data` parameter for this kind of repository.
        """
        assert isinstance(query, str)

        conn = self.get_connection()
        items = []

        for schema_name in conn._db["data"]:
            input_db = conn._db["data"][schema_name]
            try:
                # Ensures that the string contains double quotes around keys and values
                query = query.replace("'", '"')  # type: ignore[reportAssignmentType]
                criteria = json.loads(query)

                for key, value in criteria.items():
                    input_db = self._evaluate_lookup(key, value, False, input_db)

                items.extend(list(input_db.values()))

            except json.JSONDecodeError:
                # FIXME Log Exception
                raise Exception("Query Malformed")
            except KeyError:
                # We encountered a repository where the key was not found
                pass

        return items

    def _create_database_artifacts(self):
        """Dummy placeholder. Nothing to do."""

    def _drop_database_artifacts(self):
        """Dummy placeholder. Nothing to do."""


class DictDAO(BaseDAO):
    """A repository for storing data in a dictionary"""

    def __repr__(self) -> str:
        return f"DictDAO <{self.entity_cls.__name__}>"

    def _set_auto_fields(self, model_obj):
        """Set the values of the auto field using counter"""
        conn = self._get_session()
        assert conn is not None

        for field_name, field_obj in fields(self.entity_cls).items():
            is_auto_increment = getattr(field_obj, "increment", False)
            if is_auto_increment:
                counter_key = f"{self.schema_name}_{field_name}"
                if not (field_name in model_obj and model_obj[field_name] is not None):
                    # Increment the counter and it should start from 1
                    counter = next(conn._db["counters"][counter_key])
                    if not counter:
                        counter = next(conn._db["counters"][counter_key])

                    model_obj[field_name] = counter

        return model_obj

    def _create(self, model_obj):
        """Write a record to the dict repository"""
        conn = self._get_session()
        assert conn is not None

        # Update the value of the counters
        model_obj = self._set_auto_fields(model_obj)

        # Add the entity to the repository
        id_fld = id_field(self.entity_cls)
        assert id_fld is not None
        identifier = model_obj[id_fld.field_name]
        with conn._db["lock"]:
            conn._db["data"][self.schema_name][identifier] = model_obj

        self._commit_if_standalone(conn)

        return model_obj

    def _filter_items(self, criteria: Q, db):
        """Recursive function to filter items from dictionary"""
        # Filter the dictionary objects based on the filters
        negated = criteria.negated
        input_db = None

        if criteria.connector == criteria.AND:
            # Trim database records over successive iterations
            #   Whatever is left at the end satisfy all criteria (AND)
            input_db = db
            for child in criteria.children:
                if isinstance(child, Q):
                    input_db = self._filter_items(child, input_db)
                else:
                    input_db = self.provider._evaluate_lookup(
                        child[0], child[1], negated, input_db
                    )
        else:
            # Grow database records over successive iterations
            #   Whatever is left at the end satisfy any criteria (OR)
            input_db = {}
            for child in criteria.children:
                if isinstance(child, Q):
                    results = self._filter_items(child, db)
                else:
                    results = self.provider._evaluate_lookup(
                        child[0], child[1], negated, db
                    )

                input_db = {**input_db, **results}

        return input_db

    def _filter(
        self, criteria: Q, offset: int = 0, limit: int = 10, order_by: list = ()
    ):
        """Read the repository and return results as per the filer"""
        conn = self._get_session()
        assert conn is not None

        if criteria.children:
            items = list(
                self._filter_items(
                    criteria, conn._db["data"][self.schema_name]
                ).values()
            )
        else:
            items = list(conn._db["data"][self.schema_name].values())

        # Sort the filtered results based on the order_by clause
        # Use compound sorting to match database behavior
        if order_by:

            def compound_sort_key(item):
                """Create a compound sort key that matches database ORDER BY behavior"""
                key_parts = []

                for o_key in order_by:
                    is_desc = o_key.startswith("-")
                    field_name = o_key[1:] if is_desc else o_key
                    value = item.get(field_name)

                    # Handle nulls consistently:
                    # - In ASC order: nulls come last
                    # - In DESC order: nulls come first
                    # We use tuples where the first element determines null vs non-null precedence
                    if value is None:
                        if is_desc:
                            # DESC: nulls should come first (smallest sort key)
                            key_parts.append((0,))
                        else:
                            # ASC: nulls should come last (largest sort key)
                            key_parts.append((2,))
                    else:
                        # Non-null values get precedence 1
                        if is_desc:
                            # For DESC order, negate numeric values or reverse string comparison
                            if isinstance(value, (int, float)):
                                key_parts.append((1, -value))
                            else:
                                # For non-numeric values (strings, dates, etc.), use reverse comparison
                                # We'll wrap in a special class that reverses all comparisons
                                key_parts.append((1, _ReverseCompare(value)))
                        else:
                            # For ASC order, use value directly
                            key_parts.append((1, value))

                return tuple(key_parts)

            items = sorted(items, key=compound_sort_key)

        result = ResultSet(
            offset=offset,
            limit=limit,
            total=len(items),
            # When limit is not set, we return all items
            items=items[offset : offset + limit] if limit else items,
        )

        return result

    def _update(self, model_obj):
        """Update the entity record in the dictionary"""
        conn = self._get_session()
        assert conn is not None

        id_fld = id_field(self.entity_cls)
        assert id_fld is not None
        identifier = model_obj[id_fld.field_name]
        with conn._db["lock"]:
            # Check if object is present
            if identifier not in conn._db["data"][self.schema_name]:
                raise ObjectNotFoundError(
                    f"`{self.__class__.__name__}` object with identifier {identifier} "
                    f"does not exist."
                )

            conn._db["data"][self.schema_name][identifier] = model_obj

        self._commit_if_standalone(conn)

        return model_obj

    def _update_all(self, criteria: Q, *args, **kwargs):
        """Update all objects satisfying the criteria"""
        conn = self._get_session()
        assert conn is not None

        items = self._filter_items(criteria, conn._db["data"][self.schema_name])

        update_count = 0
        for key in items:
            item = items[key]
            item.update(*args)
            item.update(kwargs)
            conn._db["data"][self.schema_name][key] = item

            update_count += 1

        self._commit_if_standalone(conn)

        return update_count

    def _delete(self, model_obj):
        """Delete the entity record in the dictionary"""
        conn = self._get_session()
        assert conn is not None

        id_fld = id_field(self.entity_cls)
        assert id_fld is not None
        identifier = model_obj[id_fld.field_name]
        with conn._db["lock"]:
            # Check if object is present
            if identifier not in conn._db["data"][self.schema_name]:
                raise ObjectNotFoundError(
                    f"`{self.entity_cls.__name__}` object with identifier {identifier} "
                    f"does not exist."
                )

            del conn._db["data"][self.schema_name][identifier]

        self._commit_if_standalone(conn)

        return model_obj

    def _delete_all(self, criteria: Q = None):
        """Delete the dictionary object by its criteria"""
        conn = self._get_session()
        assert conn is not None
        items = []

        if criteria:
            # Delete the object from the dictionary and return the deletion count
            items = self._filter_items(criteria, conn._db["data"][self.schema_name])

            # Delete all the matching identifiers
            with conn._db["lock"]:
                for identifier in items:
                    conn._db["data"][self.schema_name].pop(identifier, None)
        else:
            with conn._db["lock"]:
                if self.schema_name in conn._db["data"]:
                    del conn._db["data"][self.schema_name]

        self._commit_if_standalone(conn)

        return len(items)

    def _raw(self, query: Any, data: Any = None) -> ResultSet:
        """Run raw query on Repository.

        For this stand-in repository, the query string is a json string that contains kwargs
        criteria with straight-forward equality checks. Individual criteria are always AND-ed
        and the result is always a subset of the full repository.

        We will ignore the `data` parameter for this kind of repository.
        """
        items = self.provider._raw(query, data)
        return ResultSet(offset=1, limit=len(items), total=len(items), items=items)

    def has_table(self) -> bool:
        """Always returns True for MemoryProvider as it is always available"""
        return True


class MemoryLookup(BaseLookup):
    """Base class for Memory provider lookups.

    Subclasses implement ``evaluate()`` which returns a boolean result
    by comparing ``self.source`` (stored value) against ``self.target``
    (filter value) directly — no string construction or eval().
    """

    def _coerce(self, value: Any) -> Any:
        """Coerce UUID/datetime/date to str for comparison."""
        if isinstance(value, (UUID, datetime, date)):
            return str(value)
        return value

    def as_expression(self) -> str:
        """Satisfy BaseLookup ABC — not used by Memory provider."""
        return ""

    def evaluate(self) -> bool:
        """Evaluate the lookup comparison. Override in subclasses."""
        raise NotImplementedError


@MemoryProvider.register_lookup
class Exact(MemoryLookup):
    """Exact Match Query"""

    lookup_name = "exact"

    def evaluate(self) -> bool:
        return self._coerce(self.source) == self._coerce(self.target)


@MemoryProvider.register_lookup
class IExact(MemoryLookup):
    """Case-Insensitive Exact Match Query"""

    lookup_name = "iexact"

    def evaluate(self) -> bool:
        return str(self.source).lower() == str(self.target).lower()


@MemoryProvider.register_lookup
class Contains(MemoryLookup):
    """Contains Query"""

    lookup_name = "contains"

    def evaluate(self) -> bool:
        return self._coerce(self.target) in self._coerce(self.source)


@MemoryProvider.register_lookup
class IContains(MemoryLookup):
    """Case-Insensitive Contains Query"""

    lookup_name = "icontains"

    def evaluate(self) -> bool:
        return str(self.target).lower() in str(self.source).lower()


@MemoryProvider.register_lookup
class Startswith(MemoryLookup):
    """Startswith Query"""

    lookup_name = "startswith"

    def evaluate(self) -> bool:
        return str(self._coerce(self.source)).startswith(str(self._coerce(self.target)))


@MemoryProvider.register_lookup
class Endswith(MemoryLookup):
    """Endswith Query"""

    lookup_name = "endswith"

    def evaluate(self) -> bool:
        return str(self._coerce(self.source)).endswith(str(self._coerce(self.target)))


@MemoryProvider.register_lookup
class GreaterThan(MemoryLookup):
    """Greater than Query"""

    lookup_name = "gt"

    def evaluate(self) -> bool:
        return self._coerce(self.source) > self._coerce(self.target)


@MemoryProvider.register_lookup
class GreaterThanOrEqual(MemoryLookup):
    """Greater than or Equal Query"""

    lookup_name = "gte"

    def evaluate(self) -> bool:
        return self._coerce(self.source) >= self._coerce(self.target)


@MemoryProvider.register_lookup
class LessThan(MemoryLookup):
    """Less than Query"""

    lookup_name = "lt"

    def evaluate(self) -> bool:
        return self._coerce(self.source) < self._coerce(self.target)


@MemoryProvider.register_lookup
class LessThanOrEqual(MemoryLookup):
    """Less than or Equal Query"""

    lookup_name = "lte"

    def evaluate(self) -> bool:
        return self._coerce(self.source) <= self._coerce(self.target)


@MemoryProvider.register_lookup
class In(MemoryLookup):
    """In Query"""

    lookup_name = "in"

    def evaluate(self) -> bool:
        target = (
            self.target if isinstance(self.target, (list, tuple)) else [self.target]
        )
        return self._coerce(self.source) in [self._coerce(t) for t in target]


@MemoryProvider.register_lookup
class Any(MemoryLookup):
    """Any Query for Lists"""

    lookup_name = "any"

    def evaluate(self) -> bool:
        source = (
            self.source if isinstance(self.source, (list, tuple)) else [self.source]
        )
        target = (
            self.target if isinstance(self.target, (list, tuple)) else [self.target]
        )
        return any(self._coerce(x) in [self._coerce(t) for t in target] for x in source)


def register() -> None:
    """Register MemoryProvider with Protean.

    MemoryProvider is always available as it has no external dependencies.
    """
    from protean.port.provider import registry

    registry.register("memory", "protean.adapters.repository.memory.MemoryProvider")
