"""Reusable mixins for list views: sortable columns + CSV/XLSX export.

Pair with ``django.views.generic.ListView``. Both mixins are independent and
compose freely — apply one or both depending on the view.
"""

from __future__ import annotations

import csv
from io import BytesIO
from typing import Callable, Iterable

from django.http import HttpResponse


class SortableListMixin:
    """Whitelist-driven ``?sort=`` support for ``ListView``.

    Set ``sortable_fields`` to a mapping of URL keys to ORM order fields. The
    ``?sort=col`` query param is ascending; ``?sort=-col`` is descending.
    Unknown keys fall back to ``default_sort`` (or the view's own ordering).
    """

    sortable_fields: dict[str, str] = {}
    default_sort: str | None = None

    def _resolved_ordering(self) -> str | None:
        raw = (self.request.GET.get("sort") or "").strip()
        if not raw:
            return self.default_sort
        descending = raw.startswith("-")
        key = raw[1:] if descending else raw
        db_field = self.sortable_fields.get(key)
        if not db_field:
            return self.default_sort
        return f"-{db_field}" if descending else db_field

    def get_ordering(self):
        # Django's MultipleObjectMixin.get_queryset() picks this up when the
        # consumer delegates to ``super().get_queryset()``. Consumers that
        # build a custom queryset (e.g. with filters) should call
        # ``self.apply_sort(qs)`` before returning.
        ordering = self._resolved_ordering()
        if ordering is not None:
            return ordering
        return super().get_ordering()

    def apply_sort(self, queryset):
        """Apply the resolved ordering to ``queryset``.

        Call at the end of a custom ``get_queryset`` that doesn't delegate
        to ``super``. No-op when neither a ``?sort`` param nor a
        ``default_sort`` is set (preserves whatever order ``queryset``
        already has).
        """
        ordering = self._resolved_ordering()
        if ordering:
            queryset = queryset.order_by(ordering)
        return queryset

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        raw = (self.request.GET.get("sort") or "").strip()
        descending = raw.startswith("-")
        ctx["sort"] = raw[1:] if descending else raw
        ctx["sort_dir"] = "desc" if descending else ("asc" if raw else None)
        ctx["sortable_fields"] = list(self.sortable_fields.keys())
        return ctx


CsvAccessor = str | Callable[[object], object]


class CsvExportMixin:
    """``?export=csv`` / ``?export=xlsx`` interception for ``ListView``.

    Returns a tabular response built from the post-filter, post-sort queryset
    using ``csv_columns`` (``[(header, attr_or_callable), ...]``). ``csv`` is
    served as ``text/csv``; ``xlsx`` as a native Excel workbook (openpyxl).
    Set ``csv_export_disabled = True`` on approval queues / work-in-progress
    surfaces where export would mislead.

    Name kept as ``CsvExportMixin`` for backwards compatibility — the columns
    declaration is format-agnostic.
    """

    csv_columns: Iterable[tuple[str, CsvAccessor]] = ()
    csv_filename: str = "export"
    csv_export_disabled: bool = False

    EXPORT_FORMATS = {"csv", "xlsx"}

    def get(self, request, *args, **kwargs):
        fmt = (request.GET.get("export") or "").lower()
        if fmt in self.EXPORT_FORMATS and not self.csv_export_disabled:
            if fmt == "xlsx":
                return self._export_xlsx()
            return self._export_csv()
        return super().get(request, *args, **kwargs)

    def _export_csv(self) -> HttpResponse:
        self.object_list = self.get_queryset()
        response = HttpResponse(content_type="text/csv")
        response["Content-Disposition"] = f'attachment; filename="{self.csv_filename}.csv"'
        writer = csv.writer(response)
        writer.writerow([label for label, _ in self.csv_columns])
        # chunk_size=1000 keeps ``.iterator()`` usable on querysets with
        # ``prefetch_related`` (Django 4.2+ requires the explicit size).
        for obj in self.object_list.iterator(chunk_size=1000):
            writer.writerow([_resolve_cell(obj, accessor) for _, accessor in self.csv_columns])
        return response

    def _export_xlsx(self) -> HttpResponse:
        from openpyxl import Workbook
        from openpyxl.styles import Font

        self.object_list = self.get_queryset()
        wb = Workbook()
        ws = wb.active
        # Excel sheet names are capped at 31 chars and may not contain certain chars.
        sheet_title = "".join(c for c in self.csv_filename if c not in "[]:*?/\\")[:31] or "Export"
        ws.title = sheet_title

        header_font = Font(bold=True)
        ws.append([label for label, _ in self.csv_columns])
        for cell in ws[1]:
            cell.font = header_font

        for obj in self.object_list.iterator(chunk_size=1000):
            ws.append([_resolve_cell(obj, accessor) for _, accessor in self.csv_columns])

        # Reasonable auto-width based on header length; openpyxl doesn't ship
        # a true auto-fit, so this is a pragmatic approximation.
        for col_idx, (label, _) in enumerate(self.csv_columns, start=1):
            ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = max(12, len(str(label)) + 2)

        buf = BytesIO()
        wb.save(buf)
        buf.seek(0)
        response = HttpResponse(
            buf.read(),
            content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        )
        response["Content-Disposition"] = f'attachment; filename="{self.csv_filename}.xlsx"'
        return response


def _resolve_cell(obj: object, accessor: CsvAccessor) -> str:
    if callable(accessor):
        value = accessor(obj)
    elif isinstance(accessor, str) and "." in accessor:
        value = obj
        for part in accessor.split("."):
            if value is None:
                break
            value = getattr(value, part, None)
    else:
        value = getattr(obj, accessor, None)
    if callable(value):
        value = value()
    return "" if value is None else str(value)
