from typing import Any, Callable, Collection, Optional, Sequence, TYPE_CHECKING  # noqa
from sequential_uuids.generators import uuid_time_nextval

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.related import ForeignKey
from django.db import router
from django.db.models import Manager, Model, BooleanField, QuerySet, TextField
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils import timezone
from django.utils.functional import cached_property

from console_deps.dirtyfields import DirtyFieldsMixin
from .fields import CanonicalIdField, LCDateTimeField
from ..exceptions import UniqueConstraintValidationError, EXISTING_RECORD_CID_KEY
from ..signals import field_changed
from ..managers import lookup_field
from ..typehints import (
    CanonicalIDOrString,
    Changes,
    FILTER_FIELDS,
    LookupField,
    OptionalPrimaryKey,
    PrimaryKey,
    TenantChange,
)
from ..utils import pretty_name

if TYPE_CHECKING:
    from sandbox.latchstring.models import User as UserType

    ManageTenantFieldsBase = type['NamesUrlsMixin']
else:
    ManageTenantFieldsBase = Model

EQUAL = ('', None)


# ---------------------------------------------------------------------------
def value_compare(new_value: Any, old_value: Any) -> bool:
    """
    Text fields may switch from None to empty string
    and back again, but this doesn't equal a change
    that we're interested in.
    """
    if new_value in EQUAL and old_value in EQUAL:
        return True
    return new_value == old_value


# ---------------------------------------------------------------------------
def has_tenant(model: Model, name: str) -> bool:
    """
    Check if Model has ForeignKey tenant field with specified name
    """
    for field in model._meta.get_fields():
        if field.name == name:
            return isinstance(field, ForeignKey)

    # Check for tenant field manually assigned as string.
    # model.appliance_field = 'accesspolicy__appliance'
    try:
        return bool(getattr(model, f'{name}_field'))
    except AttributeError:
        return False


# ---------------------------------------------------------------------------
class NamesUrlsMixin(Model):
    """
    Console models that subclass FilterFields or ManageTenantFields
    should construct their own class in the local console project,
    including this mixin as one of the superclasses.

    Define permissions-oriented attributes on every model
    Define timestamp & URL-related fields
    """

    if TYPE_CHECKING:
        name: TextField
        builtin: BooleanField
        objects: Manager = QuerySet.as_manager()

        # Records can be defined as having "special" names,
        # meaning that the names are reserved for specific
        # uses / situations. Normal records should not be
        # named with these names.
        reserved_names: set[str]

    soft_delete = False
    monitor_for_config_refresh = False
    css_icon = ''
    md_description = ''

    # Activity streams normally use "self" as action object.
    # On relation tables, other properties may be more meaningful.
    action_object = ''
    action_target = ''

    # All models should have the field used to calculate relationship
    # to a given Company. Set these attributes on individual
    # models to related fields / PKs as is needful.
    company_field = ''

    # By default, the model name is used for URL prefixes. But assign
    # url_prefix attribute to override for other naming patterns.
    url_prefix = ''

    cid = CanonicalIdField(db_index=True, unique=True)
    created = LCDateTimeField(default=timezone.now)
    modified = LCDateTimeField(default=timezone.now)
    is_active = BooleanField(default=True)

    class Meta:
        abstract = True

    @cached_property
    def get_pretty_name(self) -> str:
        try:
            return pretty_name(
                name=self.name,  # noqa
                prefixes=settings.PRETTY_NAME_SCRUB_PREFIXES,
                suffixes=settings.PRETTY_NAME_SCRUB_SUFFIXES,
            )
        except AttributeError:
            return getattr(self, 'name', 'N/A')  # noqa

    def get_app_label(self) -> str:
        if self._meta:
            return self._meta.app_label
        return 'N/A'

    def rule_base(self):
        """
        Get rule base string with placeholder so action can be added.

        mediaroom.add_channel
        accounts.change_company
        """
        return f'{self.get_app_label()}.%s_{self.model_name()}'

    @property
    def select_field_name(self) -> str:
        return self.get_pretty_name

    def css_text_class(self) -> str:
        return 'text-default' if self.is_active else 'text-muted'

    @property
    def row_pk(self):
        """DataTables helper attribute"""
        return f'row_{self.pk}'

    def cast_pk(self, value):
        """Cast value to UUID for use as primary key"""
        try:
            return int(value)
        except AttributeError:
            return value

    def get_detail_url_name(self) -> str:
        return f'{self.get_app_label()}:{self.model_name()}_detail'

    def get_absolute_url(self) -> str:
        try:
            return reverse(self.get_detail_url_name(), kwargs={'pk': self.pk})
        except NoReverseMatch:
            return '#'

    def get_activity_stream_url(self) -> str:
        try:
            kwargs = {
                'app': self.get_app_label(),
                'model': self.concrete_model_name(),
                'cid': self.cid,
            }
            return reverse('frontend:activity_stream_record', kwargs=kwargs)
        except NoReverseMatch:
            return '#'

    def get_activity_stream_model_deletes_url(self) -> str:
        try:
            kwargs = {
                'app': self.get_app_label(),
                'model': self.concrete_model_name(),
                'operation': 'delete',
            }
            return reverse('frontend:activity_stream_record', kwargs=kwargs)
        except NoReverseMatch:
            return '#'

    def concrete_model_name(self) -> str:
        if model := self._meta.concrete_model:
            return model().__class__.__name__.lower()
        return ''

    def model_name(self) -> str:
        """
        By default, the model name is used for URL prefixes.
        Check for 'url_prefix' property for alternate name scheme.
        """
        url_prefix = getattr(self, 'url_prefix', '')
        return url_prefix or self.__class__.__name__.lower()

    def pretty_model_name(self) -> str:
        """Override on subclasses to customize"""
        if name := self._meta.verbose_name:
            return name.title()
        return ''

    def pretty_model_name_plural(self) -> str:
        """Override on subclasses to customize"""
        if name := self._meta.verbose_name_plural:
            return name.title()
        return ''

    @cached_property
    def get_url_base_name(self) -> str:
        return f'{self.get_app_label()}:{self.model_name()}'

    def get_list_url_name(self) -> str:
        """For list views where endless pagination means page param will increment"""
        try:
            reverse(f'{self.get_url_base_name}_list')
            return f'{self.get_url_base_name}_list'
        except NoReverseMatch:
            return '#'

    def get_list_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_list')
        except NoReverseMatch:
            return '#'

    def get_create_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_create')
        except NoReverseMatch:
            return '#'

    def get_update_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_update', kwargs={'pk': self.pk})
        except NoReverseMatch:
            return '#'

    def get_clone_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_clone', kwargs={'pk': self.pk})
        except NoReverseMatch:
            return '#'

    def get_delete_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_delete', kwargs={'pk': self.pk})
        except NoReverseMatch:
            return '#'

    def get_toggle_url(self) -> str:
        try:
            return reverse(f'{self.get_url_base_name}_toggle', kwargs={'pk': self.pk})
        except NoReverseMatch:
            return '#'

    def get_field_names(self) -> set[str]:
        return set(self.get_model_fields().keys())

    def get_model_fields(self) -> dict:
        return {f.name: f for f in self._meta.get_fields()}

    def enable(self) -> None:
        if not self.is_active:
            self.is_active = True
            return self.save(update_fields=['is_active'])

    def disable(self) -> None:
        if self.is_active:
            self.is_active = False
            return self.save(update_fields=['is_active'])

    def clone_setup(self, **kwargs: Any) -> dict:
        """
        Reset pk / date / sync fields to new values
        """
        now = timezone.now()
        reset = {
            'id': None,
            'pk': None,
            'cid': uuid_time_nextval(),
            'created': now,
            'modified': now,
            'builtin': False,
        }
        reset.update(kwargs)
        fields = self.get_field_names()

        if 'name' in fields and 'name' not in reset:
            reset['name'] = f'{getattr(self, "name")} copied at {now.isoformat()}'
        if 'code' in fields and 'code' not in reset:
            reset['code'] = f'{getattr(self, "code")}_{int(now.timestamp())}'

        return reset

    def clone(self, **kwargs: Any) -> 'NamesUrlsMixin':
        bulk_save = kwargs.pop('bulk_save', False)
        fields = self.clone_setup(**kwargs)
        obj = self
        obj._state.adding = True

        for k, v in fields.items():
            setattr(obj, k, v)

        if not bulk_save:
            obj.save()

        return obj

    def toggle(self) -> None:
        """
        Activate the record if inactive, and vice versa.
        """
        # Toggling should be done on the Subscriber, and often
        # a scheduler is involved to re-toggle the action.
        # So, don't update `modified` field, to minimize sync churn.
        self.is_active = not self.is_active
        return self.save(update_fields=['is_active'])

    def browse(self, obj_pk: PrimaryKey) -> Optional['NamesUrlsMixin']:
        """
        Return an instance of the object.
        :param obj_pk: Object or PK
        :return:
        """
        if not obj_pk:
            return None
        if isinstance(obj_pk, self.__class__):
            return obj_pk
        return self.__class__.objects.filter(pk=obj_pk).first()

    def scid(self, cid: CanonicalIDOrString) -> Optional['NamesUrlsMixin']:
        """
        Return an instance of the object based on Canonical ID.
        If model has no 'cid' field, lookup checks PK
        :param cid: Object or Canonical ID
        :return:
        """
        if not cid:
            return None
        if isinstance(cid, self.__class__):
            return cid
        if 'cid' in self.get_field_names():
            lookup = {'cid': cid}
        else:
            lookup = {'pk': cid}
        return self.__class__.objects.filter(**lookup).first()

    @property
    def is_builtin(self) -> bool:
        """Builtin objects should not be removed by any user"""
        try:
            return self.builtin  # noqa
        except AttributeError:
            return False

    def unremovable(self) -> bool:
        """
        Some records should not be deleted via API or console.
        Especial consideration is syncing, where a record may
        have been deleted, and deletes synced.

        This may not have been wanted if the user had really
        understood the ramifications of deleting ALL instances
        of the record on ALL systems.

        Override on subclasses to customize behavior.
        """
        try:
            return self.is_builtin
        except AttributeError:
            return False

    def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]:
        """
        If self.soft_delete==True and self.is_active==True, deactivate.
        In all other situations, delete record.
        """

        if self.soft_delete and self.is_active:
            self.is_active = False
            self.save()
            return 0, {}

        return super().delete(*args, **kwargs)


# ---------------------------------------------------------------------------
class ManageTenantFields(
    DirtyFieldsMixin,
    ManageTenantFieldsBase,  # type: ignore
):
    """
    Manage Company / Policy tenant fields
    """

    compare_function: tuple[Callable, dict] = (value_compare, {})

    # Reserved names for preset models. New records should
    # not be created by end users via forms, and existing
    # records with one of these names should not be changed.
    reserved_names: set[str] = set()

    # Console object permissions are based on Company or Policy fields
    # All models should have the field used to calculate relationship
    # to a given Company or Policy. Set these attributes on individual
    # models to related fields / PKs as is needful.
    company_field = ''

    # By default, the model name is used for URL prefixes. But assign
    # url_prefix attribute to override for other naming patterns.
    url_prefix = ''

    if TYPE_CHECKING:
        company_id: type[int]
        policy_id: type[int]
        is_synced: bool
        IS_SYNCABLE: bool
        GUARD_SYNC_DELETES_FROM_SUBSCRIBER: bool

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.changed_tenant_fields: dict[str, TenantChange] = {}

    class Meta:
        abstract = True

    def get_field_names(self) -> set[str]:
        return {f.name for f in self._meta.get_fields()}

    def get_dirty_fields(
        self,
        check_relationship: bool = False,
        check_m2m: dict | None = None,
        verbose: bool = False,
    ) -> dict:
        """
        Override so we always check FK relationships
        """
        return super().get_dirty_fields(
            check_relationship=True,
            check_m2m=check_m2m,
            verbose=verbose,
        )

    def is_dirty(
        self,
        check_relationship: bool = False,
        check_m2m: dict | None = None,
    ) -> bool:
        return super().is_dirty(check_relationship=True, check_m2m=check_m2m)

    @cached_property
    def get_content_type(self) -> ContentType:
        """
        Override on Models with sub Proxy
        models to return proxy model class
        """
        return ContentType.objects.get_for_model(self.__class__)

    @cached_property
    def get_content_type_id(self) -> Optional[int]:
        try:
            return self.get_content_type.pk
        except AttributeError:
            return None

    @property
    def assigned_to_company(self) -> bool:
        """
        Objects are typically controlled by company and can have 3 states
            1. No company attribute - available to all companies
            2. Has company attribute assigned - available only to that company
            3. Has company attribute, but no assigned ID - available to all companies
        """
        if hasattr(self, 'company') and self.company_id:
            return True

        return False

    def is_related(self, user: 'UserType') -> bool:
        """
        Checks here should apply to ALL objects.
        Override on sub-classed models if necessary...
        """
        # users should be able to edit themselves
        if user == self:
            return True

        # only superusers can edit other superusers
        if getattr(self, 'is_superuser', False) and not user.is_superuser:
            return False

        has_company = getattr(self, 'company_id', None)
        has_policy = getattr(self, 'policy_id', None)

        if has_policy:
            return self.related_by_policy(user)

        if has_company:
            return self.related_by_company(user)

        return user.is_accountability and not has_company and not has_policy

    def related_by_company(self, user: 'UserType') -> bool:
        """Override on subclasses to refine logic"""
        return self.company_id in user.company_relations_cache

    def related_by_policy(self, user: 'UserType') -> bool:
        """Override on subclasses to refine logic"""
        return self.policy_id in user.policy_relations_cache

    def is_preset(self) -> bool:
        try:
            return getattr(self, 'name', '').lower() in self.reserved_names
        except AttributeError:
            return False

    @cached_property
    def policy_controlled(self) -> bool:
        if not getattr(self, 'policy_id', None):
            return False

        return self.policy_id is not None

    @property
    def policy_name(self) -> str:
        """
        Helper attributes to make rest_framework easy to use with datatables
        Won't have to add SerializerMethodField all the time.

        datatables support nested serializers but it's easiest to send empty
        data if there's no FK relationship
        """
        try:
            return self.policy.name
        except AttributeError:
            return ''

    @property
    def policy_pk(self) -> OptionalPrimaryKey:
        """Helper for rest_framework / datatables. See policy_name above."""
        try:
            return self.policy.pk
        except AttributeError:
            return None

    @property
    def company_name(self) -> str:
        """
        Helper attributes to make rest_framework easy to use with datatables
        Won't have to add SerializerMethodField all the time.

        datatables support nested serializers but it's easiest to send empty
        data if there's no FK relationship
        """
        try:
            return self.company.name
        except AttributeError:
            return ''

    @property
    def company_pk(self) -> OptionalPrimaryKey:
        """Helper for rest_framework / datatables. See company_name above."""
        try:
            return self.company.pk
        except AttributeError:
            return None

    @cached_property
    def has_appliance_tenant_field(self) -> bool:
        return has_tenant(model=self, name='appliance')

    @cached_property
    def has_company_tenant_field(self) -> bool:
        return has_tenant(model=self, name='company')

    @cached_property
    def has_policy_tenant_field(self) -> bool:
        return has_tenant(model=self, name='policy')

    def has_appliance_tenant(self) -> bool:
        return self.has_appliance_tenant_field and getattr(self, 'appliance_id', None) is not None

    def has_company_tenant(self) -> bool:
        return self.has_company_tenant_field and getattr(self, 'company_id', None) is not None

    def has_policy_tenant(self) -> bool:
        return self.has_policy_tenant_field and getattr(self, 'policy_id', None) is not None

    def is_tenant_model(self) -> bool:
        """
        If this is a Tenant model, then we need to be careful where it's
        deleted, so that a user doesn't delete it on a Subscriber without
        realizing the extent of deletion to all other systems.
        """
        return self.model_name() in settings.TENANT_FIELDS

    def is_universal(self) -> bool:
        """
        Record has both Company and Policy tenant fields, but neither are assigned.
        """
        has_company_tenant = self.has_company_tenant_field
        if has_company_tenant and getattr(self, 'company_id', None):
            return False

        has_policy_tenant = self.has_policy_tenant_field
        if has_policy_tenant and getattr(self, 'policy_id', None):
            return False

        has_appliance_tenant = self.has_appliance_tenant_field
        if has_appliance_tenant and getattr(self, 'appliance_id', None):
            return False

        # Check by foreignkey relations and properties assigned to models
        has_company_rel = has_policy_rel = has_appliance_rel = False
        if not has_company_tenant and (has_company_rel := hasattr(self, 'company_field')):
            if bool(getattr(self, 'company_pk', None) or getattr(self, 'company_id', None)):
                return False

        if not has_policy_tenant and (has_policy_rel := hasattr(self, 'policy_field')):
            if bool(getattr(self, 'policy_pk', None) or getattr(self, 'policy_id', None)):
                return False

        if not has_appliance_tenant and (has_appliance_rel := hasattr(self, 'appliance_field')):
            if bool(getattr(self, 'appliance_pk', None) or getattr(self, 'appliance_id', None)):
                return False

        if not any((
            has_appliance_tenant,
            has_company_tenant,
            has_policy_tenant,
            has_company_rel,
            has_policy_rel,
            has_appliance_rel,
        )):
            return False

        return True

    def before_after_field_values(self, field: str, dirty_fields: dict) -> Changes:
        """
        Check if field value changed on existing record.
        Only call this method if record is being updated rather than created.

        If the values have changed, send signal with sender, field name & old/new values.
        """

        try:
            new = getattr(self, field)
        except AttributeError:
            return Changes(None, None)

        try:
            old = dirty_fields[field]
        except KeyError:
            old = new

        if old != new:
            field_changed.send(
                sender=self.__class__,
                instance=self,
                field=field,
                old=old,
            )

        return Changes(old, new)

    def clean_model_data(self, creating: bool, called_from_save: bool = True) -> None:  # noqa
        """
        Override on subclasses to extend the record validation logic.
        """
        if called_from_save:
            self.clean()

    def full_clean(self, *args: Any, **kwargs: Any) -> None:
        """
        Call clean_model_data from full_clean to ensure
        that ModelForms trigger this validation.
        """
        super().full_clean(*args, **kwargs)  # noqa
        self.clean_model_data(creating=self._state.adding, called_from_save=False)

    def save(self, *args: Any, **kwargs: Any) -> None:
        """
        Override to call clean_model_data to trigger full_clean operations.
        Set call_clean=False on model forms to disable re-calling `clean_model_data` if needful.
        """
        if kwargs.pop('call_clean', True) and not kwargs.get('update_fields'):
            self.clean_model_data(creating=self._state.adding, called_from_save=True)
        super().save(*args, **kwargs)  # noqa


# ---------------------------------------------------------------------------
class FilterFields:
    # On large tables, `count` queries are extremely expensive in Postgres.
    # The pg_class table can be queried to get an approximate total record
    # count, which will be as up-to-date as the last vacuum.
    #
    # Large tables, such as Log tables can query this table to get
    # approximate row counts. Should be done mostly to keep UI pages as
    # performant as possible. The usual "cache invalidation" concerns apply.
    CACHE_COUNT_QUERIES: bool = False

    @classmethod
    def lookup_field(cls, name: str) -> LookupField:
        """
        Most models have a Company or Policy field and we usually want
        to limit querysets based on one or both of those fields. However,
        related objects should be limited by a "through" field.

        Check to see if the model has a through-field defined,
        or an actual field of the specified name.

        Return field name string, and bool of whether field is required.
        Filtering by required fields don't need to include NULL values
        for more efficient queries.
        """
        return lookup_field(cls, name)  # type: ignore[arg-type]

    def filter_fields(self, skip: Sequence = ()) -> FILTER_FIELDS:
        """
        The list of fields that support querying by get params
        Override on subclassed models to customize
        """
        model_fields = {f.name for f in self._meta.fields}  # type: ignore[attr-defined]

        if 'date' in model_fields:
            date_field = 'date'
        else:
            date_field = 'created'

        fields = [
            ('name__icontains', 'name'),
            (f'{date_field}__date__range', 'date_range'),
        ]

        for field in ('company', 'policy'):
            if field in model_fields:
                fields.append((field, field))

        if not skip:
            return fields

        return [(qkey, value) for qkey, value in fields if qkey not in skip]


# ---------------------------------------------------------------------------
class ValidateConstraints(Model):
    """
    Override default handling of constraints in Model._meta to ensure that
    fields are not excluded when constraints on the model require them.
    """

    # If a constraint field is only relevant in the Local context,
    # and not on the Publisher or other Subscribers, include it here
    # to streamline syncing.
    LOCAL_CONSTRAINT_FIELDS: frozenset[str] = frozenset()

    class Meta:
        abstract = True

    def full_clean(self, *args: Any, **kwargs: Any) -> None:
        """
        Add existing_record_cid property to ValidationError
        if it's been set on the record object.
        """
        try:
            return super().full_clean(*args, **kwargs)
        except ValidationError as error:
            if existing_record_cid := getattr(self, EXISTING_RECORD_CID_KEY, None):
                raise UniqueConstraintValidationError(
                    error,
                    **{EXISTING_RECORD_CID_KEY: existing_record_cid},
                ) from None
            raise error

    def validate_constraints(self, exclude: Collection[str] | None = None) -> None:
        """
        Overridden in toto from superclass:
           * To assign single-field errors to that field
           * To ensure that no fields needed in constraints are excluded
        """
        errors: dict = {}
        constraints = self.get_constraints()
        using = router.db_for_write(self.__class__, instance=self)
        exclude_fields = self.excluded_fields(constraints, exclude)
        existing_record_cid = None

        for model_class, model_constraints in constraints:
            for constraint in model_constraints:
                try:
                    constraint.validate(  # type: ignore
                        model_class,
                        self,
                        exclude=exclude_fields,
                        using=using,
                    )
                except ValidationError as e:
                    if not existing_record_cid:
                        existing_record_cid = getattr(e, EXISTING_RECORD_CID_KEY, None)

                    constraint_fields = getattr(constraint, 'fields', [])

                    # Show all single-field errors, and not use those with code "unique"
                    if error_field := getattr(constraint, 'error_field', ''):
                        errors.setdefault(error_field, []).append(e)
                    elif len(constraint_fields) == 1:
                        errors.setdefault(constraint_fields[0], []).append(e)
                    else:
                        errors = e.update_error_dict(errors)

        if errors:
            error_data = {
                'message': errors,
            }
            if existing_record_cid:
                setattr(self, EXISTING_RECORD_CID_KEY, existing_record_cid)
                error_data[EXISTING_RECORD_CID_KEY] = existing_record_cid

            raise UniqueConstraintValidationError(**error_data)

    def excluded_fields(
        self, constraints: list[tuple], exclude: Collection[str] | None
    ) -> set[str]:
        """
        Check all constraints that depend on fields and ensure that
        those fields are not part of the excluded fields!

        Override on subclassed models to customize how fields are excluded.
        Sometimes fields need to remain excluded so that form validation
        completes successfully.
        """
        if not constraints or not exclude:
            return set(exclude or '')

        excluded_fields = set(exclude)
        constraint_fields = set()

        for model_class, model_constraints in constraints:
            if not isinstance(self, model_class):
                continue
            for constraint in model_constraints:
                try:
                    constraint_fields.update(constraint.fields)
                except AttributeError:
                    continue

        if constraint_fields:
            return excluded_fields.difference(constraint_fields)

        return excluded_fields

    def concordia_full_clean(self, serializer_errors: Any = None) -> dict:
        """
        Variation of model.full_clean, to be called by Concordia sync operations.

        We'll call all field validation checks and return errors of each type, to
        maximize the possibility of record conflict resolution.
        """
        errors = {'serializer_errors': serializer_errors} if serializer_errors else {}

        try:
            self.clean_fields()
        except ValidationError as e:
            errors['clean_fields'] = e

        try:
            self.validate_unique()
        except ValidationError as e:
            errors['validate_unique'] = e

        try:
            self.validate_constraints()
        except ValidationError as e:
            errors['validate_constraints'] = e

        return errors


__all__ = (
    'FilterFields',
    'ManageTenantFields',
    'NamesUrlsMixin',
    'ValidateConstraints',
)
