"""
Contains with all View classes except for Permission / Layout mixins
Use these base classes in console applications generate the final Views.
"""

import logging
from typing import Any, Sequence, TYPE_CHECKING
from uuid import UUID

from django.contrib import messages
from django.conf import settings
from django.db.models import Q, QuerySet
from django.db.models.signals import post_save
from django.forms import ModelForm
from django.http import (
    HttpResponse,
    HttpResponseRedirect,
)
from django.urls import reverse, NoReverseMatch
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as tr

from console_base.forms import NameFilterForm
from .mixins import (
    CrumbTrail,
    LCUnpolyCrispyFormViewMixin,
    LCUnpolyViewMixin,
    TenantView,
    ObjectURLs,
    PrefillForm,
    ScrubbedGetParams,
    ToggleOrDeleteMixin,
)

from console_base.http import FastJsonResponse
from console_base.permissions import (
    LCLoginRequiredMixin,
    LCLoginOrTokenRequiredMixin,  # noqa convenience import
)
from console_base.typehints import LCHttpRequest, StrOrPromise

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

    from vanilla import (
        CreateView,
        DeleteView,
        DetailView,
        GenericView,
        ListView,
        UpdateView,
    )

    GenericMixin = type[GenericView]
    CreateViewMixin = type[CreateView]
    UpdateViewMixin = type[UpdateView]
    ListViewMixin = type[ListView]
    DetailViewMixin = type[DetailView]
    DeleteViewMixin = DeleteView
else:
    GenericMixin = CreateViewMixin = UpdateViewMixin = ListViewMixin = DetailViewMixin = (
        DeleteViewMixin
    ) = object

logger = logging.getLogger(__name__)


def typed_key(k: int | str | UUID) -> int | UUID:
    """
    Cast primary key to either UUID or int
    """
    if isinstance(k, (UUID, int)):
        return k

    try:
        return UUID(k)
    except (AttributeError, ValueError):
        return int(k)


# ----------------------------------------------------------------------
class LCGenericViewBase(ScrubbedGetParams, LCUnpolyCrispyFormViewMixin, GenericMixin):  # type: ignore
    action = 'view'

    def get(self, request: LCHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:  # noqa
        context = self.get_context_data()
        return self.render_to_response(context)


# ----------------------------------------------------------------------
class ReSortViewBase(ObjectURLs, LCUnpolyCrispyFormViewMixin, GenericMixin):  # type: ignore
    """
    Post dict will have a dictionary of {'id': '<sequence>'} values
    {
        'c8c75380-d11d-11e7-9ea7-0010184a8fec': 1,
        '9938431c-d19b-11e7-9c7e-0010184a8fec': 3,
        'a95247fc-d19b-11e7-8397-0010184a8fec': 2,
    }
    Primary keys will be saved with updated Sequence integer
    """

    action = 'update'
    model = None
    sort_field = 'sequence'

    def get_success_url(self) -> str:
        return ''

    def success_response(self) -> HttpResponse:
        if not self.get_success_url():
            raise NotImplementedError('Override get_success_url or success_response for each view')

        return HttpResponseRedirect(self.get_success_url())

    def get_pks(self, postlist: list[str]) -> dict[str, int]:
        """
        Convert list of strings like ['pk|2', 'pk|1']  to dict of {'pk': 'seq'} values,
        where sequence has of "pk" has been updated to order of list.
        """
        pks = []
        sequences = []
        for pk, seq in [v.split('|') for v in postlist]:
            try:
                sequences.append(int(seq.strip()))
            except ValueError:
                logger.error('Unable to sort %s / %s', pk, seq)
                continue
            pks.append(pk.strip())
        sequences.sort()

        values = {}

        for i, sequence in enumerate(sequences):
            values[pks[i]] = sequence

        return values

    def resort(self, primarykeys: dict[str, int]) -> bool:
        """
        Takes dictionary of {'pk': <sequence>} values
        and persists new sorted values to database.
        """
        if not self.model:
            raise ValueError('A model must be defined')

        if not primarykeys or len(primarykeys) == 1:
            return True

        # handle integer PKs and UUIDs both as string and hex values
        typed_pks = {}
        for k, v in primarykeys.items():
            typed_pks[typed_key(k)] = v

        records = self.model.objects.filter(pk__in=typed_pks.keys()).only('pk', self.sort_field)
        if not records:
            return False

        for record in records:
            setattr(record, self.sort_field, typed_pks[record.pk])

        try:
            self.model.objects.bulk_update(records, [self.sort_field])
        except Exception as e:
            logger.exception('An error occurred while saving updated sort %s', e)
            return False

        # trigger save, to persist restart_banner
        post_save.send(
            self.model,
            instance=records[0],
            created=False,
        )

        return True

    def post(self, request: LCHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
        """
        Lookup the sequence integers of those records in the database
        and apply those records to the new sort order of the list
        """
        post_data = dict(request.POST)
        if sort_sequence := post_data.get('sort_sequence'):
            primarykeys = self.get_pks(sort_sequence)  # noqa
        else:
            primarykeys = {pk: int(seq[0].strip()) for pk, seq in dict(request.POST).items()}

        if self.resort(primarykeys):
            return self.success_response()

        return HttpResponse(
            b'Unable to update sequence',
            status=500,
        )


# ----------------------------------------------------------------------
class LCOpenFormViewBase(PrefillForm, LCUnpolyCrispyFormViewMixin):
    action = 'update'


# ----------------------------------------------------------------------
class LCFormViewBase(ObjectURLs, PrefillForm, LCUnpolyCrispyFormViewMixin):
    action = 'update'


# ----------------------------------------------------------------------
class LCRedirectViewBase(LCLoginRequiredMixin, ScrubbedGetParams):
    action = 'view'


# ----------------------------------------------------------------------
class LCCreateViewBase(
    ObjectURLs,
    PrefillForm,
    LCUnpolyCrispyFormViewMixin,
    CreateViewMixin,  # type: ignore
):
    """
    Override get method to pre-populate form with GET values
    """

    action = 'create'

    def show_bookmark_button(self) -> bool:
        return False

    def show_create_btn(self) -> bool:
        return False

    def get_unpoly_target(self):
        """
        Override on views to refine if necessary. Will especially be
        necessary on where records are being replaced in tables.
        """
        return settings.MAIN_UP_TARGET

    def form_valid(self, form: ModelForm) -> HttpResponse:
        """
        If this form is on second or higher layer,
        accept layer so select field can be updated.
        """
        select_field_id = self.request.GET.get('parent_select_field_id', '')
        if self.up.is_unpoly() and select_field_id:
            return self.send_accept_layer(form, select_field_id)

        return super().form_valid(form)


# ----------------------------------------------------------------------
class LCUpdateViewBase(
    TenantView,
    ObjectURLs,
    ScrubbedGetParams,
    LCUnpolyCrispyFormViewMixin,
    UpdateViewMixin,  # type: ignore
):
    action = 'update'

    def show_bookmark_button(self) -> bool:
        return False

    def show_create_btn(self) -> bool:
        return False

    def perform_unpoly_validation(self, request: LCHttpRequest) -> HttpResponse:
        """
        Unpoly form validation calls form validation but should not save form
        """
        self.object = self.get_object()
        form = self.get_form(
            data=request.POST,
            files=request.FILES,
            instance=self.object,
            up_validate=True,
        )
        form.is_valid()
        return self.form_invalid(form)

    def post(self, request: LCHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
        if self.request.unpoly_validate():
            return self.perform_unpoly_validation(request=request)

        return super().post(request, *args, **kwargs)


# ----------------------------------------------------------------------
class LCOpenTemplateViewBase(ScrubbedGetParams, LCUnpolyViewMixin):
    action = 'view'
    load_form_in_modal = True


# ----------------------------------------------------------------------
class LCTemplateViewBase(CrumbTrail, ScrubbedGetParams, LCUnpolyViewMixin):
    action = 'view'

    @cached_property
    def css_icon(self) -> str:
        return 'fas fa-tachometer-alt'


# ----------------------------------------------------------------------
class LCDataTablesListBase(
    ObjectURLs,
    LCTemplateViewBase,
):
    model = None
    # how large a day range should we filter by initially?
    # the larger the table, the lower this value should be.
    initialdayrange = 1

    def crud_name(self) -> str:
        if model_obj := self.model_object():
            return str(model_obj._meta.verbose_name_plural).title()  # type: ignore[union-attr]
        return ''

    def show_record_activity_stream_btn(self) -> bool:
        """
        The Activity Stream button is to display the stream
        for a specific model, which doesn't apply in list view.
        """
        return False

    def show_record_activity_stream_deletes_btn(self) -> bool:
        if not self.concord_event_tracked_model():
            return False

        if not self.request.user.is_authenticated:
            return False

        try:
            return self.request.user.has_perm(settings.SHOW_ACTIVITY_STREAM_PERM)
        except AttributeError:
            logger.info('SHOW_ACTIVITY_STREAM_PERM setting is not defined')
            return False

    def show_context_menu_btn(self) -> bool:
        return self.show_record_activity_stream_deletes_btn()

    def table_filters(self) -> str:
        return ''


# ----------------------------------------------------------------------
class LCListViewBase(
    TenantView,
    ObjectURLs,
    ScrubbedGetParams,
    LCUnpolyViewMixin,
):
    action = 'view'
    filter_form = NameFilterForm
    hide_fields = ['status']
    load_form_in_modal = False
    display_filter_form = True

    def __init__(self) -> None:
        super().__init__()
        self.paginate_by = 25
        self.allow_empty = True

    # -----------------------------------------------------
    def is_detail_view(self) -> bool:
        """
        Detail pages can use list view,
        but are modified to set an object on the view.
        """
        if getattr(self, 'object', None):
            return True
        return False

    def name(self) -> StrOrPromise:
        if self.is_detail_view():
            return f'{getattr(self, "object")} Details'

        try:
            return str(self.model_object()._meta.verbose_name_plural).title()  # type: ignore[union-attr]
        except AttributeError:
            return ''

    # -----------------------------------------------------
    def status_qs(self) -> Q:
        """
        Return queryset limited by the status param of a URL
        """
        status = self.scrubbed_get_params().get('status', '')

        if not status:
            return Q()

        if status == 'active':
            return Q(is_active=True)

        return Q(is_active=False)

    # -----------------------------------------------------
    def get_context_data(self, **kwargs: Any) -> dict:
        context = super().get_context_data(**kwargs)
        context['crud'] = self.action

        if self.display_filter_form:
            context['filter_form'] = self.filter_form(
                self.scrubbed_get_params() or None,
                user=self.request.user,
                action=self.get_list_url_name(),
                hide_fields=self.hide_fields,
            )
        return context

    def user_related(
        self,
        user: 'UserType',
        tenant: int | UUID | None,
        cached_pks: Sequence[int | str | UUID],
    ) -> bool:
        """
        Check if user is related to this object.
        """
        if not user.is_authenticated:
            return False

        return tenant in cached_pks

    def get_queryset(self) -> QuerySet:
        params = {}
        user: 'UserType' = self.request.user  # noqa

        form = self.filter_form(data=self.request.GET)
        if form.is_valid():
            params = form.cleaned_data
        else:
            logger.error('Filter form errors are %s', form.errors)

        qs = super().get_queryset()
        company = get_param_pk('company', params)
        policy = get_param_pk('policy', params)

        # Bail out early here if user is unrelated to tenant rather
        # than performing a more expensive query on the table
        if not user.is_reseller:
            if not self.user_related(user, company, user.company_relations_cache):
                return qs.none()

            if not self.user_related(user, policy, user.policy_relations_cache):
                return qs.none()

        try:
            return qs.tenant(user=self.request.user).search(**params)  # type: ignore[attr-defined]
        except AttributeError:
            logger.info('%s is not a tenanted model; are you sure this is correct!?!', qs.model)
            return qs.search(**params)  # type: ignore[attr-defined]


# ----------------------------------------------------------------------
def get_param_pk(key: str, params: dict) -> int | UUID:
    """
    Get the integer primary key value from param,
    or if it's a native object, get the "id" value.
    """
    if value := params.pop(key, 0):
        if hasattr(value, 'id'):
            return value.id
        return int(value)
    return 0


# ----------------------------------------------------------------------
class LCDetailViewBase(
    TenantView,
    ObjectURLs,
    ScrubbedGetParams,
    LCUnpolyViewMixin,
    DetailViewMixin,  # type: ignore
):
    action = 'view'

    def get_object(self) -> 'BaseUUIDPKModel':
        """
        If `cid` URL kwarg is passed in, then used `cid` as lookup field.
        """
        if 'cid' in self.kwargs:
            self.lookup_field = 'cid'
        return super().get_object()

    def name(self) -> StrOrPromise:
        try:
            return tr('{} Details').format(self.get_object().get_pretty_name or "")
        except AttributeError:
            return tr('{} Details').format(self.get_object() or "")

    def get_create_url(self) -> str:
        """
        Add tenant ID to query params to auto-complete Company / Policy field
        on the form, on the assumption that if a person is creating a new record
        from a Detail page, that the same Company or Policy is wanted for the
        new record as is defined on the present record.
        """
        url = super().get_create_url()

        # Check for company and auto-assign that if it exists but don't assign
        # policy by default, as that heightens the Permission requirements,
        # so it should be deliberate assignment
        company_id = getattr(self.object, 'company_id', None)
        if company_id:
            return f'{url}?company={company_id}'

        policy_id = getattr(self.object, 'policy_id', None)
        if policy_id:
            return f'{url}?policy={policy_id}'

        return url

    def render_to_response(self, context: Any) -> HttpResponse:
        # select2 fields may make an ajax request to select and populate
        # a value via javascript API rather than user selection action.
        # https://select2.org/programmatic-control/add-select-clear-items#preselecting-options-in-an-remotely-sourced-ajax-select2
        if self.up.template_type() == 'select2':
            return FastJsonResponse({
                'id': self.object.id,
                'term': self.object.name,
            })
        return super().render_to_response(context=context)  # noqa

    def get_warning_messages(self) -> list:
        """
        Override on subclasses to display warning about the record.

          * Company doesn't have an owner assigned.
          * Manually Specified Recipients report has no recipients.
        """
        return []

    # -----------------------------------------------------
    def show_clone_btn(self) -> bool:
        if not self.request.user.is_authenticated:
            return False
        perm = self.request.user.has_calculated_perm('add')  # noqa
        if perm is False:
            return perm

        url = self.object.get_clone_url()
        return bool(url) and url != '#'

    # -----------------------------------------------------
    def show_delete_btn(self) -> bool:
        if not self.request.user.is_authenticated or self.object.unremovable():
            return False
        if self.object.is_preset():
            return False
        if self.object.policy_controlled and not self.request.user.is_accountability:  # noqa
            return False
        if self.object.is_universal() and not self.request.user.is_superuser:
            return False
        has_perm = self.request.user.has_calculated_perm('delete', self.object)  # noqa
        if has_perm is not None:
            return has_perm
        return self.request.user.is_company_user or self.request.user.is_accountability  # noqa

    # -----------------------------------------------------
    def show_sync_menu_btn(self) -> bool:
        return False

    # -----------------------------------------------------
    def show_context_menu_btn(self) -> bool:
        return self.show_record_activity_stream_btn()

    def get(self, request: LCHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
        # call superclass method first, so self.object is defined
        # for use in get_warning_message
        resp = super().get(request, *args, **kwargs)  # noqa

        for warning in self.get_warning_messages() or []:
            messages.error(request, warning)

        return resp


# ----------------------------------------------------------------------
class LCCloneViewBase(LCUpdateViewBase):
    """
    Clone a copy of the object and open
    that new object in UpdateView
    """

    action = 'clone'

    # -----------------------------------------------------
    def dispatch(self, request: LCHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
        obj = self.get_object().clone()
        return HttpResponseRedirect(f'{obj.get_update_url()}?{settings.CLONING_RECORD_KEY}=True')


# ----------------------------------------------------------------------
class LCDeleteViewBase(
    TenantView,
    ObjectURLs,
    ScrubbedGetParams,
    LCUnpolyViewMixin,
    ToggleOrDeleteMixin,
):
    """
    Delete view to handle both regular HTTP Posts and Ajax posts to the view.

    When the URL contains a "remove=<selector>" query param, return a 204 status code.
    Otherwise, redirect to get_success_url.
    """

    def __init__(self) -> None:
        super().__init__()
        self.action = 'delete'
        self.delete_mode = ''
        self.constraint_check_failed = False
        self.reload_detail_page = False

    def get_warning_title(self) -> StrOrPromise:
        """
        Main warning question to display when presenting the user
        with a Confirm Deletion dialog.
        """
        return tr('Are you sure you want to %s %s?') % (self.action, self.object)

    def get_special_warnings(self) -> list[str]:
        """
        Deleting some objects will have broader ramifications than
        the user might be considering, so display messages to user
        in the Delete Confirmation dialog when accessing this view
        via the GET method.

        Override on subclasses to extend the messages.
        """

        return []

    def get_context_data(self, **kwargs: Any) -> dict:
        return super().get_context_data(
            special_warnings=self.get_special_warnings() if self.request.method == 'GET' else [],
            **kwargs,
        )

    def get_success_url(self) -> str:
        if self.action != 'delete':
            messages.info(self.request, f'{self.object} has been {self.action}d')
            list_url = self.object.get_list_url_name()

            if self.action == 'de-activate':
                try:
                    return reverse(f'{list_url}_inactive')
                except NoReverseMatch:
                    pass

            return reverse(list_url)

        if self.constraint_check_failed or self.reload_detail_page:
            return self.object.get_absolute_url()

        return self.success_url or self.object.get_list_url()


__all__ = (
    'typed_key',
    'LCGenericViewBase',
    'ReSortViewBase',
    'LCOpenFormViewBase',
    'LCFormViewBase',
    'LCRedirectViewBase',
    'LCCreateViewBase',
    'LCUpdateViewBase',
    'LCOpenTemplateViewBase',
    'LCTemplateViewBase',
    'LCDataTablesListBase',
    'LCListViewBase',
    'LCDetailViewBase',
    'LCCloneViewBase',
    'LCDeleteViewBase',
)
