from copy import deepcopy
from functools import cached_property
from urllib.parse import urlencode

import django_tables2 as tables
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
from django.db.models.fields.reverse_related import ManyToOneRel
from django.urls.exceptions import NoReverseMatch
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.data import TableQuerysetData
from django_tables2.utils import OrderBy

from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
from utilities.html import highlight
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.string import title
from utilities.views import get_action_url

from .template_code import *

__all__ = (
    'BaseTable',
    'NestedGroupModelTable',
    'NetBoxTable',
    'OrganizationalModelTable',
    'PrimaryModelTable',
    'SearchTable',
)


class BaseTable(tables.Table):
    """
    Base table class for NetBox objects. Adds support for:

        * User configuration (column preferences)
        * Automatic prefetching of related objects
        * BS5 styling

    :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
    """
    exempt_columns = ()

    class Meta:
        attrs = {
            'class': 'table table-hover object-list',
        }

    # TODO: Remove user kwarg in NetBox v4.7
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Set default empty_text if none was provided
        if self.empty_text is None:
            self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)

    def _get_columns(self, visible=True):
        columns = []
        for name, column in self.columns.items():
            if column.visible == visible and name not in self.exempt_columns:
                columns.append((name, column.verbose_name))
        return columns

    @property
    def name(self):
        return self.__class__.__name__

    @property
    def available_columns(self):
        return sorted(self._get_columns(visible=False))

    @property
    def selected_columns(self):
        return self._get_columns(visible=True)

    @property
    def objects_count(self):
        """
        Return the total number of real objects represented by the Table. This is useful when dealing with
        prefixes/IP addresses/etc., where some table rows may represent available address space.
        """
        if not hasattr(self, '_objects_count'):
            self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
        return self._objects_count

    def _set_columns(self, selected_columns):
        """
        Update the table sequence to display only the named columns and any exempt columns.
        """
        # Hide non-selected columns which are not exempt
        for column in self.columns:
            if column.name not in [*selected_columns, *self.exempt_columns]:
                self.columns.hide(column.name)

        # Rearrange the sequence to list selected columns first, followed by all remaining columns
        # TODO: There's probably a more clever way to accomplish this
        self.sequence = [
            *[c for c in selected_columns if c in self.columns.names()],
            *[c for c in self.columns.names() if c not in selected_columns]
        ]

        # PK column should always come first
        if 'pk' in self.sequence:
            self.sequence.remove('pk')
            self.sequence.insert(0, 'pk')

        # Actions column should always come last
        if 'actions' in self.sequence:
            self.sequence.remove('actions')
            self.sequence.append('actions')

    def _apply_prefetching(self, columns=None):
        """
        Dynamically update the table's QuerySet to ensure related fields are pre-fetched.

        Args:
            columns: An optional iterable of column names for which to apply prefetching,
                regardless of visibility. If None, only currently visible columns are used.
        """
        if not isinstance(self.data, TableQuerysetData):
            return

        prefetch_fields = []
        for column in self.columns.iterall():
            if columns is not None:
                if column.name not in columns:
                    continue
            elif not column.visible:
                # Skip hidden columns
                continue
            model = getattr(self.Meta, 'model')  # Must be called *after* resolving columns
            accessor = column.accessor
            if accessor.startswith('custom_field_data__'):
                # Ignore custom field references
                continue
            prefetch_path = []
            for field_name in accessor.split(accessor.SEPARATOR):
                try:
                    field = model._meta.get_field(field_name)
                except FieldDoesNotExist:
                    break
                if isinstance(field, (RelatedField, ManyToOneRel)):
                    # Follow ForeignKeys to the related model
                    prefetch_path.append(field_name)
                    model = field.remote_field.model
                elif isinstance(field, GenericForeignKey):
                    # Can't prefetch beyond a GenericForeignKey
                    prefetch_path.append(field_name)
                    break
            if prefetch_path:
                prefetch_fields.append('__'.join(prefetch_path))
        self.data.data = self.data.data.prefetch_related(*prefetch_fields)

    def _get_custom_field_ordering_columns(self, order_by):
        """
        Return the custom field columns among those named by the given ordering.

        Args:
            order_by: An iterable (or comma-separated string) of order by aliases.
        """
        order_by = order_by.split(',') if isinstance(order_by, str) else order_by or ()
        ordering_columns = []
        for alias in order_by:
            name = OrderBy(alias).bare
            # Ignore any aliases which django-tables2 will itself discard
            if name not in self.columns or not self.columns[name].orderable:
                continue
            if isinstance(column := self.columns[name].column, columns.CustomFieldColumn):
                ordering_columns.append(column)
        return ordering_columns

    def _apply_ordering_annotations(self, ordering_columns):
        """
        Dynamically annotate the table's QuerySet with the expressions needed to sort by the given
        custom field columns. These are applied only for the columns actually being ordered by, to
        avoid burdening every query with expressions it has no use for.
        """
        annotations = {}
        for column in ordering_columns:
            annotations.update(column.get_ordering_annotation())

        # Skip any annotations already applied, as when the ordering is set more than once
        if annotations := {
            name: expr for name, expr in annotations.items()
            if name not in self.data.data.query.annotations
        }:
            self.data.data = self.data.data.annotate(**annotations)

    def _apply_ordering_tie_breaker(self):
        """
        Append the primary key to the table's ordering as a final sort key, so that the ordering is
        total. Rows tying on every preceding key -- and every object holding no value for a custom
        field ties on both of that column's keys -- are otherwise free to come back in a different
        order for each query, which would cause paginated results to skip or repeat rows from one
        page to the next.
        """
        ordering = self.data.data.query.order_by
        if ordering and not any(OrderBy(o).bare in ('pk', 'id') for o in ordering):
            self.data.data = self.data.data.order_by(*ordering, 'pk')

    @tables.Table.order_by.setter
    def order_by(self, value):
        """
        Extend the ordering of the table's data with the support needed by custom field columns.
        """
        if not isinstance(self.data, TableQuerysetData):
            tables.Table.order_by.fset(self, value)
            return

        if ordering_columns := self._get_custom_field_ordering_columns(value):
            self._apply_ordering_annotations(ordering_columns)
        tables.Table.order_by.fset(self, value)
        if ordering_columns:
            self._apply_ordering_tie_breaker()

    def configure(self, request):
        """
        Configure the table for a specific request context. This performs pagination and records
        the user's preferred columns & ordering logic.
        """
        columns = None
        ordering = None

        if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
            if request.GET[self.prefixed_order_by_field]:
                # If an ordering has been specified as a query parameter, save it as the
                # user's preferred ordering for this table.
                ordering = request.GET.getlist(self.prefixed_order_by_field)
                request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
            else:
                # If the ordering has been set to none (empty), clear any existing preference.
                request.user.config.clear(f'tables.{self.name}.ordering', commit=True)

        # If the user has a saved preference, apply it
        if request.user.is_authenticated and (userconfig := request.user.config):
            if columns is None:
                columns = userconfig.get(f"tables.{self.name}.columns")
            if ordering is None:
                ordering = userconfig.get(f"tables.{self.name}.ordering")
            if userconfig.get("ui.tables.striping"):
                self.attrs['class'] += ' table-striped'

        # Fall back to the default columns & ordering
        if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
            columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
        if columns is None:
            columns = getattr(self.Meta, 'default_columns', self.Meta.fields)

        self._set_columns(columns)

        # Apply column inclusion/exclusion (overrides user preferences)
        if columns_param := request.GET.get('include_columns'):
            for column_name in columns_param.split(','):
                if column_name in self.columns.names():
                    self.columns.show(column_name)
        if exclude_columns := request.GET.get('exclude_columns'):
            exclude_columns = exclude_columns.split(',')
            for column_name in exclude_columns:
                if column_name in self.columns.names() and column_name not in self.exempt_columns:
                    self.columns.hide(column_name)

        self._apply_prefetching()
        if ordering is not None:
            self.order_by = ordering

        # Paginate the table results
        paginate = {
            'paginator_class': EnhancedPaginator,
            'per_page': get_paginate_count(request)
        }
        tables.RequestConfig(request, paginate).configure(self)

    @property
    def configuration(self):
        config = {
            'columns': ','.join([c[0] for c in self.selected_columns]),
        }
        if self.order_by:
            config['ordering'] = self.order_by
        return config

    @property
    def config_params(self):
        if not (model := getattr(self.Meta, 'model', None)):
            return None
        return urlencode({
            'object_type': ObjectType.objects.get_for_model(model).pk,
            'table': self.name,
            **self.configuration,
        })


class NetBoxTable(BaseTable):
    """
    Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes
    default columns for:

        * PK (row selection)
        * ID
        * Actions
    """
    pk = columns.ToggleColumn(
        visible=False
    )
    id = tables.Column(
        linkify=True,
        verbose_name=_('ID')
    )
    actions = columns.ActionsColumn()

    exempt_columns = ('pk', 'actions')
    embedded = False

    class Meta(BaseTable.Meta):
        pass

    def __init__(self, *args, extra_columns=None, **kwargs):
        if extra_columns is None:
            extra_columns = []

        if registered_columns := registry['tables'].get(self.__class__):
            extra_columns.extend([
                # Create a copy to avoid modifying the original Column
                (name, deepcopy(column)) for name, column in registered_columns.items()
            ])

        # Add columns for custom fields
        custom_fields = [
            cf for cf in CustomField.objects.get_for_model(self._meta.model)
            if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
        ]
        extra_columns.extend([
            (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
        ])

        # Add columns for custom links
        object_type = ObjectType.objects.get_for_model(self._meta.model)
        custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
        extra_columns.extend([
            (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
        ])

        super().__init__(*args, extra_columns=extra_columns, **kwargs)

    def configure(self, request):
        # Remove custom link columns referencing CustomLinks the user cannot view (#22439).
        # These columns are added for all enabled CustomLinks in __init__(), before the request
        # (and thus the user) is known, so object-level permissions are enforced here instead.
        self._restrict_customlink_columns(request.user)

        super().configure(request)

    def _restrict_customlink_columns(self, user):
        """
        Exclude any custom link columns which reference a CustomLink the user does not have
        permission to view.
        """
        customlinks = {
            name: column.column.customlink
            for name, column in self.columns.iteritems()
            if isinstance(column.column, columns.CustomLinkColumn)
        }
        if not customlinks:
            return

        permitted = set(
            CustomLink.objects.restrict(user, 'view').filter(
                pk__in=[cl.pk for cl in customlinks.values()]
            ).values_list('pk', flat=True)
        )
        excluded = tuple(
            name for name, customlink in customlinks.items() if customlink.pk not in permitted
        )
        if excluded:
            self.exclude = (*self.exclude, *excluded)

    @cached_property
    def htmx_url(self):
        """
        Return the base HTML request URL for embedded tables.
        """
        if self.embedded:
            try:
                return get_action_url(self._meta.model, action='list')
            except NoReverseMatch:
                pass
        return ''


class PrimaryModelTable(NetBoxTable):
    owner_group = tables.Column(
        accessor='owner__group',
        linkify=True,
        verbose_name=_('Owner Group'),
    )
    owner = tables.Column(
        linkify=True,
        verbose_name=_('Owner'),
    )
    comments = columns.MarkdownColumn(
        verbose_name=_('Comments'),
    )


class OrganizationalModelTable(NetBoxTable):
    owner_group = tables.Column(
        accessor='owner__group',
        linkify=True,
        verbose_name=_('Owner Group'),
    )
    owner = tables.Column(
        linkify=True,
        verbose_name=_('Owner'),
    )
    comments = columns.MarkdownColumn(
        verbose_name=_('Comments'),
    )


class NestedGroupModelTable(NetBoxTable):
    owner_group = tables.Column(
        accessor='owner__group',
        linkify=True,
        verbose_name=_('Owner Group'),
    )
    owner = tables.Column(
        linkify=True,
        verbose_name=_('Owner'),
    )
    name = columns.MPTTColumn(
        verbose_name=_('Name'),
        linkify=True
    )
    parent = tables.Column(
        verbose_name=_('Parent'),
        linkify=True,
    )
    comments = columns.MarkdownColumn(
        verbose_name=_('Comments'),
    )


class SearchTable(tables.Table):
    object_type = columns.ContentTypeColumn(
        verbose_name=_('Type'),
        order_by="object___meta__verbose_name",
    )
    object = tables.Column(
        verbose_name=_('Object'),
        linkify=True,
        order_by=('name', )
    )
    field = tables.Column(
        verbose_name=_('Field'),
    )
    value = tables.Column(
        verbose_name=_('Value'),
    )
    attrs = columns.TemplateColumn(
        template_code=SEARCH_RESULT_ATTRS,
        verbose_name=_('Attributes')
    )

    trim_length = 30

    class Meta:
        attrs = {
            'class': 'table table-hover object-list',
        }
        empty_text = _(EMPTY_TABLE_TEXT)

    def __init__(self, data, highlight=None, **kwargs):
        self.highlight = highlight
        super().__init__(data, **kwargs)

    def render_field(self, value, record):
        try:
            model_field = record.object._meta.get_field(value)
            return title(model_field.verbose_name)
        except FieldDoesNotExist:
            return value

    def render_value(self, value):
        if not self.highlight:
            return value

        value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length)

        return mark_safe(value)
