"""Staff back office: upload documents, manage owners.

These screens exist instead of pointing staff at Django's admin, which groups
things by code module and exposes the database model. Everything here is
organised by the job being done.
"""
import json
import os
import secrets
import string
from collections import Counter
from datetime import timedelta

from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.forms import PasswordResetForm
from django.db.models import Max, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_POST

from django.db import models
from documents.models import BoardMember, Category, Collection, Document, LoginPhoto

from accounts.models import LoginEvent
from accounts.tour import TOUR_VERSION

from .naming import suggest_title
from .sanitise import clean_html
from .suggest import extract_date, group_name_for, suggest_category

User = get_user_model()

staff_required = user_passes_test(lambda u: u.is_active and u.is_staff)

# Documents only. Anything that a browser might execute in our origin — html,
# svg, js — is refused outright, whatever it claims to be.
ALLOWED_EXTENSIONS = {
    ".pdf", ".doc", ".docx", ".odt", ".rtf", ".txt",
    ".xls", ".xlsx", ".csv", ".ods",
    ".ppt", ".pptx", ".odp",
    ".jpg", ".jpeg", ".png",
}
MAX_UPLOAD_BYTES = 64 * 1024 * 1024


def _password(length=14):
    """A readable, strong password. No look-alike characters, so it survives
    being read down the phone or copied by hand."""
    alphabet = "".join(c for c in string.ascii_letters + string.digits if c not in "lIO01")
    return "".join(secrets.choice(alphabet) for _ in range(length))


def _send_invite(request, user):
    """The welcome email for an account staff just created.

    Same set-a-password link as a reset, different words: the recipient never
    asked for anything, so "someone asked to reset your password" would read
    as a mistake or a phish.
    """
    from django.contrib.auth.tokens import default_token_generator
    from django.core.mail import send_mail
    from django.template.loader import render_to_string
    from django.utils.encoding import force_bytes
    from django.utils.http import urlsafe_base64_encode

    if not user.email:
        return False
    link = request.build_absolute_uri(reverse(
        "password_reset_confirm",
        kwargs={"uidb64": urlsafe_base64_encode(force_bytes(user.pk)),
                "token": default_token_generator.make_token(user)},
    ))
    from django.conf import settings
    body = render_to_string("registration/invite_email.txt", {
        "site_name": settings.SITE_NAME, "link": link,
        "username": user.get_username(),
    })
    send_mail("You have been invited to %s" % settings.SITE_NAME,
              body, None, [user.email])
    return True


def _send_reset(request, user):
    """Send the standard password-reset email to one user."""
    if not user.email:
        return False
    form = PasswordResetForm({"email": user.email})
    if not form.is_valid():
        return False
    form.save(
        request=request,
        use_https=request.is_secure(),
        from_email=None,
        email_template_name="registration/password_reset_email.html",
        subject_template_name="registration/password_reset_subject.txt",
    )
    return True


# --------------------------------------------------------------------------
# Upload
# --------------------------------------------------------------------------

@staff_required
def upload(request):
    """The uploader now lives on the library page; keep this URL working."""
    return redirect(f"{reverse('documents:library')}?upload=1")


def _upload_context():
    """Shared by the library page, which hosts the uploader in a dialog."""
    return {
        "section": "upload",
        "categories": Category.objects.all(),
        "collections": Collection.objects.all()[:100],
        "max_mb": MAX_UPLOAD_BYTES // (1024 * 1024),
        "allowed": " ".join(sorted(ALLOWED_EXTENSIONS)),
    }


@staff_required
@require_POST
def upload_suggest(request):
    """Analyse a chosen batch: titles, dates, a category, and a group proposal.

    Everything returned is a suggestion. The intent is that a batch arrives
    already named, dated and filed, leaving the group's name as the only thing
    worth typing.
    """
    uploads = request.FILES.getlist("files")
    files, dates, extensions = [], [], []

    for uploaded in uploads:
        extension = os.path.splitext(uploaded.name)[1].lower()
        found = extract_date(uploaded.name)
        category, confidence = suggest_category([uploaded.name], extension)
        files.append({
            "name": uploaded.name,
            "size": uploaded.size,
            "title": suggest_title(uploaded),
            "kind": extension.lstrip(".")[:4] or "file",
            "date": found.isoformat() if found else None,
            "category": category.pk if category else None,
            "confidence": confidence,
            "ok": extension in ALLOWED_EXTENSIONS and uploaded.size <= MAX_UPLOAD_BYTES,
        })
        if found:
            dates.append(found)
        extensions.append(extension)

    # The group's own suggestion looks at the batch as a whole, which is a
    # stronger signal than any single filename.
    common_extension = Counter(extensions).most_common(1)[0][0] if extensions else ""
    group_category, group_confidence = suggest_category(
        [u.name for u in uploads], common_extension
    )
    group_date = Counter(dates).most_common(1)[0][0] if dates else timezone.now().date()

    return JsonResponse({
        "files": files,
        "group": {
            "name": group_name_for(group_date),
            "date": group_date.isoformat(),
            "category": group_category.pk if group_category else None,
            "category_name": group_category.name if group_category else None,
            "confidence": group_confidence,
        },
        "recent": [
            {"id": c.pk, "title": c.title,
             "date": c.occurred_on.isoformat() if c.occurred_on else None,
             "count": c.documents.count()}
            for c in Collection.objects.all()[:3]
        ],
    })


@staff_required
def collection_search(request):
    """Find an older group to add files to."""
    query = (request.GET.get("q") or "").strip()
    found = Collection.objects.all()
    if query:
        found = found.filter(title__icontains=query)
    return JsonResponse({"results": [
        {"id": c.pk, "title": c.title,
         "date": c.occurred_on.isoformat() if c.occurred_on else None,
         "count": c.documents.count()}
        for c in found[:20]
    ]})


@staff_required
@require_POST
def upload_commit(request):
    """Create documents from a batch.

    Files carry their own category and date, which the browser has already
    defaulted from the group; anything the user overrode arrives as-is. A file
    may also be routed to an existing collection instead of the new group.
    """
    files = request.FILES.getlist("files")
    if not files:
        return JsonResponse({"error": "No files were received."}, status=400)

    titles = request.POST.getlist("titles")
    file_dates = request.POST.getlist("dates")
    file_categories = request.POST.getlist("categories")
    targets = request.POST.getlist("targets")          # "" = the new group, else a collection id

    group_title = (request.POST.get("group_name") or "").strip()
    group_date = request.POST.get("group_date") or None
    group_category_id = request.POST.get("group_category") or None
    group_category = (
        Category.objects.filter(pk=group_category_id).first() if group_category_id else None
    )

    def at(values, index):
        return values[index] if index < len(values) else ""

    new_group = None
    needs_group = any(not at(targets, i) for i in range(len(files))) and len(files) > 1
    if group_title and needs_group:
        base = slugify(group_title)[:200] or "collection"
        slug, suffix = base, 2
        while Collection.objects.filter(slug=slug).exists():
            slug, suffix = f"{base}-{suffix}", suffix + 1
        # The group itself carries no category — the batch's suggested category
        # is applied to each document below instead, where it can be corrected
        # one at a time.
        new_group = Collection.objects.create(
            title=group_title[:200], slug=slug, occurred_on=group_date or None,
        )

    created, rejected = [], []
    touched = set()
    for index, uploaded in enumerate(files):
        extension = os.path.splitext(uploaded.name)[1].lower()
        if extension not in ALLOWED_EXTENSIONS:
            rejected.append({"name": uploaded.name, "why": f"{extension or 'no extension'} not allowed"})
            continue
        if uploaded.size > MAX_UPLOAD_BYTES:
            rejected.append({"name": uploaded.name, "why": "larger than the size limit"})
            continue

        target_id = at(targets, index)
        collection = (
            Collection.objects.filter(pk=target_id).first() if target_id else new_group
        )
        category_id = at(file_categories, index) or group_category_id
        category = Category.objects.filter(pk=category_id).first() if category_id else None

        document = Document(
            title=(at(titles, index).strip() or suggest_title(uploaded))[:255],
            category=category,
            collection=collection,
            published=True,
            published_date=at(file_dates, index) or group_date or timezone.now().date(),
            uploaded_by=request.user,
        )
        document.file.save(uploaded.name, uploaded, save=False)
        document.save()
        created.append({"id": document.pk, "title": document.title})
        if collection:
            touched.add(collection.title)

    return JsonResponse({
        "created": created,
        "rejected": rejected,
        "groups": sorted(touched),
    })


# --------------------------------------------------------------------------
# Owners
# --------------------------------------------------------------------------

@staff_required
def people(request):
    """The owner list, filtered by the five views staff actually ask for.

    "Deleted" means deactivated, not erased: the account is kept so it can be
    put back, which is the same bargain the document trash makes. Erasing for
    good is offered only from inside that view, where the intent is explicit.

    "Active" is a rolling year rather than a calendar one. An owners' portal is
    used in bursts — an AGM, a special assessment — so someone who read the
    budget eleven months ago is a live account, not a dormant one.
    """
    query = request.GET.get("q", "").strip()
    # "Last signed in" must come from real sign-ins, never from `last_login`:
    # Django stamps that for any server-side `login()` too, which put dates
    # against owners who had never once signed in.
    real_signin = Q(loginevent__succeeded=True) & ~Q(
        loginevent__source=LoginEvent.INTERNAL)
    users = User.objects.annotate(
        last_seen=Max("loginevent__occurred_at", filter=real_signin))
    if query:
        users = users.filter(
            Q(username__icontains=query) | Q(email__icontains=query)
            | Q(first_name__icontains=query) | Q(last_name__icontains=query)
        )

    a_year_ago = timezone.now() - timedelta(days=365)
    current = Q(is_active=True)
    views = {
        "": current,
        "owners": current & Q(is_staff=False),
        "staff": current & Q(is_staff=True),
        "active": current & Q(last_seen__gte=a_year_ago),
        "deleted": Q(is_active=False),
    }
    labels = {"": "All current", "owners": "Owners", "staff": "Staff",
              "active": "Active", "deleted": "Deleted"}
    show = request.GET.get("show", "")
    if show not in views:
        show = ""
    users = users.filter(views[show])

    everyone = User.objects.annotate(
        last_seen=Max("loginevent__occurred_at", filter=real_signin))
    return render(request, "backoffice/people.html", {
        "section": "people",
        "people": users.order_by("username")[:400],
        "query": query,
        "show": show,
        "showing_deleted": show == "deleted",
        # Counts describe the whole list, not the current search — they say
        # which view is worth opening, so a search must not move them.
        "filters": [
            {"key": key, "label": labels[key], "count": everyone.filter(q).count(),
             "is_on": key == show}
            for key, q in views.items()
        ],
        "new_password": request.session.pop("new_password", None),
        "new_password_for": request.session.pop("new_password_for", None),
    })


@staff_required
@require_POST
def person_add(request):
    username = (request.POST.get("username") or "").strip()
    email = (request.POST.get("email") or "").strip()
    first = (request.POST.get("first_name") or "").strip()
    last = (request.POST.get("last_name") or "").strip()
    method = request.POST.get("method", "email")

    if not username and email:
        username = email.split("@")[0]
    if not username:
        messages.error(request, "A username or email address is required.")
        return redirect(reverse("backoffice:people") + "?add=1")
    if User.objects.filter(Q(username__iexact=username) | Q(email__iexact=email or "\0")).exists():
        messages.error(request, f"An account already exists for “{username or email}”.")
        return redirect(reverse("backoffice:people") + "?add=1")

    user = User(username=username, email=email, first_name=first[:150], last_name=last[:150])

    if method == "password":
        password = _password()
        user.set_password(password)
        user.save()
        # Held for one render only, then popped — never written to the database.
        request.session["new_password"] = password
        request.session["new_password_for"] = user.username
        messages.success(request, f"Created {user.username}. Copy the password below — it isn't shown again.")
    else:
        # A random password nobody is told. Django's reset form only emails
        # accounts that have a usable password, so set_unusable_password()
        # here would silently send nothing.
        user.set_password(_password(24))
        user.save()
        if _send_invite(request, user):
            messages.success(request, f"Created {user.username} and emailed them an invitation to set their password.")
        else:
            messages.warning(request, f"Created {user.username}, but no email address was given, so no link was sent.")
    return redirect("backoffice:people")


@staff_required
@require_POST
def person_reset_link(request, pk):
    user = get_object_or_404(User, pk=pk)
    if _send_reset(request, user):
        messages.success(request, f"Sent {user.username} a password reset link.")
    else:
        messages.error(request, f"{user.username} has no email address on file.")
    return redirect(request.POST.get("next") or "backoffice:people")


@staff_required
@require_POST
def person_new_password(request, pk):
    user = get_object_or_404(User, pk=pk)
    password = _password()
    user.set_password(password)
    user.save(update_fields=["password"])
    if request.headers.get("x-requested-with") == "XMLHttpRequest":
        # Shown once in a dialog beside the row — the page never moves.
        return JsonResponse({"username": user.username, "password": password})
    request.session["new_password"] = password
    request.session["new_password_for"] = user.username
    messages.success(request, f"New password set for {user.username}. Copy it below — it isn't shown again.")
    return redirect(request.POST.get("next") or "backoffice:people")


@staff_required
@require_POST
def person_toggle_active(request, pk):
    user = get_object_or_404(User, pk=pk)
    if user == request.user:
        messages.error(request, "You can't delete your own account.")
        return redirect("backoffice:people")
    user.is_active = not user.is_active
    user.save(update_fields=["is_active"])
    messages.success(
        request,
        f"{user.username} was put back." if user.is_active
        else f"{user.username} was deleted — you can put them back from the Deleted list.",
    )
    return redirect(request.POST.get("next") or "backoffice:people")


@staff_required
@require_POST
def person_delete(request, pk):
    user = get_object_or_404(User, pk=pk)
    if user == request.user:
        messages.error(request, "You can't delete your own account.")
        return redirect("backoffice:people")
    if user.is_superuser and User.objects.filter(is_superuser=True).count() <= 1:
        messages.error(request, "That's the only administrator — delete it from the list instead.")
        return redirect("backoffice:people")
    name = user.username
    user.delete()
    messages.success(request, f"{name} was erased for good.")
    return redirect("backoffice:people")


@staff_required
@require_POST
def view_toggle(request):
    """Flip between staff view and owner view (rendering only).

    Turning owner view ON while standing on a staff page would leave the
    demo on a page owners cannot reach, so it lands on the library instead.
    """
    turning_on = not request.session.get("owner_view")
    request.session["owner_view"] = turning_on
    target = request.POST.get("next") or ""
    if not target.startswith("/") or target.startswith("//"):
        target = reverse("documents:library")
    if turning_on and target.startswith("/staff/"):
        target = reverse("documents:library")
    return redirect(target)


# --------------------------------------------------------------------------
# Sign-in scene photographs (unlisted page, staff only)
# --------------------------------------------------------------------------

SCENE_WIDTH, SCENE_HEIGHT = 1800, 1200


def _fit_scene(upload):
    """Normalise an upload to the scene's size and weight.

    Everything is centre-cropped to one shape so the slideshow never jumps,
    and re-encoded so a 12 MB camera original does not become the sign-in
    page's download. Returns a ContentFile ready to save.
    """
    from io import BytesIO

    from django.core.files.base import ContentFile
    from PIL import Image, ImageOps

    image = Image.open(upload)
    image = ImageOps.exif_transpose(image)
    image = ImageOps.fit(
        image.convert("RGB"), (SCENE_WIDTH, SCENE_HEIGHT), method=Image.LANCZOS
    )
    buffer = BytesIO()
    image.save(buffer, format="JPEG", quality=68, optimize=True, progressive=True)
    return ContentFile(buffer.getvalue())


@staff_required
def scene_photos(request):
    """Manage the sign-in backdrop. Not linked from anywhere on purpose —
    staff reach it by URL. Still staff-gated: unlisted is not a permission."""
    photos = LoginPhoto.objects.all()
    starters = photos.filter(is_starter=True).count()
    return render(request, "backoffice/scene.html", {
        "photos": photos, "count": photos.count(),
        "starters": starters,
        "width": SCENE_WIDTH, "height": SCENE_HEIGHT,
        "section": "scene",
    })


@staff_required
@require_POST
def scene_add(request):
    uploads = request.FILES.getlist("photos")
    if not uploads:
        messages.error(request, "Choose at least one photograph.")
        return redirect("backoffice:scene_photos")
    added, skipped = 0, []
    start = (LoginPhoto.objects.aggregate(models.Max("order"))["order__max"] or 0) + 1
    for offset, upload in enumerate(uploads):
        try:
            data = _fit_scene(upload)
        except Exception:
            # A PDF renamed .jpg, a corrupt file — name it rather than 500.
            skipped.append(upload.name)
            continue
        photo = LoginPhoto(order=start + offset, caption=upload.name[:160])
        photo.image.save("scene-%s.jpg" % slugify(upload.name)[:40], data, save=True)
        added += 1
    if added:
        messages.success(request, "Added %d photograph%s." % (added, "" if added == 1 else "s"))
    if skipped:
        messages.warning(request, "Could not read: %s." % ", ".join(skipped[:5]))
    return redirect("backoffice:scene_photos")


@staff_required
@require_POST
def scene_starter(request, pk):
    """Mark (or unmark) a photograph as one that may appear FIRST."""
    photo = get_object_or_404(LoginPhoto, pk=pk)
    photo.is_starter = not photo.is_starter
    photo.save(update_fields=["is_starter"])
    return redirect("backoffice:scene_photos")


@staff_required
@require_POST
def scene_delete(request, pk):
    photo = get_object_or_404(LoginPhoto, pk=pk)
    if LoginPhoto.objects.count() <= 1:
        messages.error(request, "That is the last photograph — add another before removing it.")
        return redirect("backoffice:scene_photos")
    photo.image.delete(save=False)
    photo.delete()
    messages.success(request, "Photograph removed.")
    return redirect("backoffice:scene_photos")


# --------------------------------------------------------------------------
# Guided tour
# --------------------------------------------------------------------------

@staff_required
@require_POST
def tour_done(request):
    """Remember that this person has finished the current tour."""
    request.user.tour_seen_version = TOUR_VERSION
    request.user.save(update_fields=["tour_seen_version"])
    return JsonResponse({"ok": True})


# --------------------------------------------------------------------------
# Editing what is already in the library
# --------------------------------------------------------------------------

def _apply_edit(obj, request, date_field):
    """Title / date / category, from a JSON body. Blank clears the field."""
    payload = json.loads(request.body or "{}")

    title = (payload.get("title") or "").strip()
    if title:
        obj.title = title[:255]

    if "date" in payload:
        # Parse rather than assign the raw string: it validates the input and
        # leaves a real date on the instance for the response to format.
        raw = (payload.get("date") or "").strip()
        setattr(obj, date_field, parse_date(raw) if raw else None)

    if "category" in payload:
        category_id = payload["category"]
        obj.category = (
            Category.objects.filter(pk=category_id).first() if category_id else None
        )

    obj.save()
    return obj


@staff_required
@require_POST
def collection_update(request, pk):
    """Rename, re-date or re-file a group.

    Changing the group's date moves its documents with it — the same rule the
    uploader uses, because a group's date is the one people actually reason
    about. A document's own date still overrides it afterwards.
    """
    collection = get_object_or_404(Collection, pk=pk)
    payload = json.loads(request.body or "{}")

    title = (payload.get("title") or "").strip()
    if title:
        collection.title = title[:200]
    if "date" in payload:
        raw = (payload.get("date") or "").strip()
        collection.occurred_on = parse_date(raw) if raw else None
    collection.save()

    # A group's date is the one thing it can meaningfully lend its documents.
    # Category is not offered here: the documents in one batch belong to
    # different categories on purpose.
    moved = 0
    if payload.get("cascade") and collection.occurred_on:
        moved = collection.documents.update(published_date=collection.occurred_on)

    return JsonResponse({
        "ok": True,
        "title": collection.title,
        "date": collection.occurred_on.isoformat() if collection.occurred_on else "",
        "moved": moved,
    })


@staff_required
@require_POST
def document_update(request, pk):
    document = get_object_or_404(Document, pk=pk)
    _apply_edit(document, request, "published_date")
    return JsonResponse({
        "ok": True,
        "title": document.title,
        "date": document.published_date.isoformat() if document.published_date else "",
        "category": document.category.name if document.category else "",
    })


# --------------------------------------------------------------------------
# Board members
# --------------------------------------------------------------------------

def _board_fields(request, member):
    member.name = (request.POST.get("name") or "").strip()[:160]
    member.title = (request.POST.get("title") or "").strip()[:160]
    member.contact_email = (request.POST.get("contact_email") or "").strip()[:254]
    member.bio = clean_html(request.POST.get("bio") or "")
    if request.FILES.get("photo"):
        member.photo = request.FILES["photo"]
    return member


@staff_required
@require_POST
def board_save(request, pk=None):
    """Add a new board member, or update an existing one."""
    member = get_object_or_404(BoardMember, pk=pk) if pk else BoardMember()
    _board_fields(request, member)
    if not member.name:
        messages.error(request, "A name is required.")
        return redirect("documents:board")
    if member.pk is None:
        last = BoardMember.objects.filter(removed_at=None).order_by("-order").first()
        member.order = (last.order + 1) if last else 0
        member.active = True
    member.save()
    messages.success(request, f"Saved {member.name}.")
    return redirect("documents:board")


@staff_required
@require_POST
def board_remove(request, pk):
    """Move a member to the removed list — recoverable, not destroyed."""
    member = get_object_or_404(BoardMember, pk=pk)
    member.removed_at = timezone.now()
    member.active = False
    member.save(update_fields=["removed_at", "active"])
    messages.success(request, f"{member.name} was removed. You can restore them below.")
    return redirect("documents:board")


@staff_required
@require_POST
def board_restore(request, pk):
    member = get_object_or_404(BoardMember, pk=pk)
    member.removed_at = None
    member.active = True
    member.save(update_fields=["removed_at", "active"])
    messages.success(request, f"{member.name} is back on the board.")
    return redirect("documents:board")


@staff_required
@require_POST
def board_reorder(request):
    """Persist a new running order (a list of ids, top first)."""
    payload = json.loads(request.body or "{}")
    ids = payload.get("order") or []
    for position, member_id in enumerate(ids):
        BoardMember.objects.filter(pk=member_id).update(order=position)
    return JsonResponse({"ok": True, "count": len(ids)})


# --------------------------------------------------------------------------
# Trash
# --------------------------------------------------------------------------

@staff_required
@require_POST
def document_trash(request, pk):
    """Move one document to the trash. The file stays on disk."""
    document = get_object_or_404(Document, pk=pk, removed_at=None)
    document.removed_at = timezone.now()
    document.save(update_fields=["removed_at"])
    messages.success(request, f"Moved “{document.title}” to the trash.")
    return redirect(request.POST.get("next") or "documents:library")


@staff_required
@require_POST
def collection_trash(request, pk):
    """Move a whole group to the trash, documents and all.

    The documents are stamped with the group's own removal time, which is what
    lets a restore put back exactly what went in together — and leave alone
    anything that was already in the trash for its own reasons.
    """
    collection = get_object_or_404(Collection, pk=pk, removed_at=None)
    moment = timezone.now()
    moved = collection.documents.filter(removed_at=None).update(removed_at=moment)
    collection.removed_at = moment
    collection.save(update_fields=["removed_at"])
    messages.success(
        request,
        f"Moved “{collection.title}” and {moved} document{'' if moved == 1 else 's'} to the trash.",
    )
    return redirect(request.POST.get("next") or "documents:library")


@staff_required
@require_POST
def document_restore(request, pk):
    document = get_object_or_404(Document, pk=pk)
    document.removed_at = None
    document.save(update_fields=["removed_at"])
    # A document cannot come back into a group that is still in the trash.
    if document.collection and document.collection.removed_at:
        document.collection = None
        document.save(update_fields=["collection"])
    messages.success(request, f"Restored “{document.title}”.")
    return redirect("backoffice:trash")


@staff_required
@require_POST
def collection_restore(request, pk):
    collection = get_object_or_404(Collection, pk=pk)
    moment = collection.removed_at
    restored = collection.documents.filter(removed_at=moment).update(removed_at=None)
    collection.removed_at = None
    collection.save(update_fields=["removed_at"])
    messages.success(
        request,
        f"Restored “{collection.title}” and {restored} document{'' if restored == 1 else 's'}.",
    )
    return redirect("backoffice:trash")


@staff_required
def trash(request):
    return render(request, "backoffice/trash.html", {
        "section": "trash",
        "documents": (Document.objects.exclude(removed_at=None)
                      .select_related("category", "collection")),
        "collections": Collection.objects.exclude(removed_at=None),
    })


@staff_required
@require_POST
def trash_empty(request):
    """Delete trashed items for good, files included."""
    documents = list(Document.objects.exclude(removed_at=None))
    for document in documents:
        try:
            document.file.delete(save=False)
        except Exception:
            pass          # a missing file should not block the record going
    count = len(documents)
    Document.objects.exclude(removed_at=None).delete()
    groups = Collection.objects.exclude(removed_at=None).count()
    Collection.objects.exclude(removed_at=None).delete()
    messages.success(
        request,
        f"Deleted {count} document{'' if count == 1 else 's'} and "
        f"{groups} group{'' if groups == 1 else 's'} for good.",
    )
    return redirect("backoffice:trash")
