"""Repository document UI — class-based views with HTMX-aware partials."""

from __future__ import annotations

import json
import mimetypes
import uuid
from datetime import timedelta
from pathlib import Path
from typing import Any

from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File as DjangoFile
from django.db.models import Count, Q
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.text import get_valid_filename
from django.views.generic import (
    CreateView,
    DeleteView,
    DetailView,
    FormView,
    ListView,
    TemplateView,
    UpdateView,
    View,
)

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group

from apps.core.authentication.models import Institution
from apps.core.lists.pagination import paginate_qs
from apps.core.permissions.mixins import RoleRequiredMixin
from apps.core.permissions.roles import UserRole
from apps.repository.documents import permissions, services
from apps.repository.documents.audit_helper import action_update, audit_repository
from apps.repository.documents.forms import (
    AccessRequestForm,
    AuthorMergeForm,
    CollectionForm,
    CollectionGroupAccessForm,
    CollectionMergeForm,
    CollectionDeprecateForm,
    CollectionRenameForm,
    CommentForm,
    DocumentMetadataEditForm,
    DocumentSubmitForm,
    GroupCreateForm,
    GroupMemberAddForm,
    KeywordReviewForm,
    QAComplianceForm,
    QARecommendForm,
    QAReviewForm,
    ReinstateForm,
    ReviewTaskForm,
    SavedSearchForm,
    ShareForm,
    VersionUploadForm,
    WithdrawForm,
)
from apps.repository.documents.models import (
    AccessRequest,
    AccessViolation,
    Author,
    Collection,
    CollectionGroupAccess,
    CollectionSlugAlias,
    Document,
    DocumentBookmark,
    DocumentComment,
    DocumentReviewTask,
    DocumentShare,
    DocumentVersion,
    Keyword,
    QARecord,
    Region,
    ResumableUpload,
    SavedSearch,
)
from apps.repository.documents.permissions import can_access, can_download
from apps.repository.documents.watermark import apply_docx_watermark, apply_watermark


# Single source of truth lives in permissions.py so visibility, management, and
# analytics access never drift apart.
REPO_MANAGER_ROLES = tuple(permissions.REPOSITORY_MANAGEMENT_ROLES)


class RepoManagerRequiredMixin(RoleRequiredMixin):
    allowed_roles = REPO_MANAGER_ROLES


class QAParticipantRequiredMixin(RoleRequiredMixin):
    """SP6 two-tier review — opens the QA queue/review pages to Reviewers and
    Publication Assistants alongside the management tier. What each role may
    *do* there (recommend / compliance-check / final decision) is enforced
    per-action in the QA views, not by this mixin."""

    allowed_roles = tuple(permissions.QA_PARTICIPANT_ROLES)


class WithdrawalAuthorityRequiredMixin(RoleRequiredMixin):
    """SP5 — withdrawal/reinstatement is the Knowledge Hub Manager's call
    (plus Editor in Chief and the admin tier); see
    ``permissions.WITHDRAWAL_AUTHORITY_ROLES``."""

    allowed_roles = tuple(permissions.WITHDRAWAL_AUTHORITY_ROLES)


def _qa_role_flags(user, qa_record=None) -> dict:
    """Per-role QA capabilities for the review page + POST guards.

    ``can_finalize`` — management tier takes the final decision.
    ``can_recommend`` — the assigned reviewer (any participant role) records a
    recommendation; finalizers don't need to, they decide directly.
    ``can_compliance`` — Publication Assistant's check; finalizers may also
    record it.
    """
    role = getattr(user, "role", "")
    is_manager = user.is_superuser or role in permissions.REPOSITORY_MANAGEMENT_ROLES
    is_assigned = bool(qa_record is not None and qa_record.reviewer_id == user.pk)
    return {
        "can_finalize": is_manager,
        "can_recommend": is_assigned and not is_manager,
        "can_compliance": is_manager or role == UserRole.PUBLICATION_ASSISTANT,
    }


# ── Browse / Detail / Submit ───────────────────────────────────────────────


class BrowseView(ListView):
    template_name = "documents/browse.html"
    paginate_by = 24

    def get_queryset(self):
        qs = Document.objects.for_user(self.request.user).select_related("collection")
        params = self.request.GET
        types = [t for t in params.getlist("type") if t]
        if types:
            qs = qs.filter(document_type__in=types)
        cols = [c for c in params.getlist("collection") if c]
        if cols:
            qs = qs.filter(collection__slug__in=cols)
        regions = [r for r in params.getlist("region") if r]
        if regions:
            qs = qs.filter(regions__slug__in=regions).distinct()
        countries = [c for c in params.getlist("country") if c]
        if countries:
            qs = qs.filter(country__in=countries)
        subjects = [s for s in params.getlist("subject") if s]
        if subjects:
            qs = qs.filter(keywords__slug__in=subjects).distinct()
        conferences = [c for c in params.getlist("conference") if c]
        if conferences:
            qs = qs.filter(conference_name__in=conferences)
        publishers = [p for p in params.getlist("publisher") if p]
        if publishers:
            qs = qs.filter(publisher__in=publishers)
        if year := params.get("year"):
            try:
                qs = qs.filter(year=int(year))
            except (TypeError, ValueError):
                pass
        if oa := params.get("oa"):
            # ?oa=1 — limit to documents the visibility rules treat as Open Access.
            qs = qs.filter(visibility=Document.Visibility.PUBLIC_OPEN)
        sort = params.get("sort", "recent")
        if sort == "title":
            qs = qs.order_by("title")
        elif sort == "oldest":
            qs = qs.order_by("published_at", "created_at")
        else:
            qs = qs.order_by("-published_at", "-created_at")
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        public_collections = Collection.objects.exclude(
            visibility=Collection.Visibility.INTERNAL
        )
        ctx["collections"] = public_collections
        ctx["document_types"] = Document.DocumentType.choices

        # Facet counts — annotate over the visibility-filtered base set so chips
        # always reflect what the user can actually reach.
        base = Document.objects.for_user(self.request.user)
        type_counts = dict(
            base.values_list("document_type")
            .annotate(c=Count("id"))
            .values_list("document_type", "c")
        )
        col_counts = dict(
            base.values_list("collection__slug")
            .annotate(c=Count("id"))
            .values_list("collection__slug", "c")
        )
        ctx["document_type_facets"] = [
            {"value": value, "label": label, "count": type_counts.get(value, 0)}
            for value, label in Document.DocumentType.choices
        ]
        ctx["collection_facets"] = [
            {"slug": c.slug, "name": c.name, "count": col_counts.get(c.slug, 0)}
            for c in public_collections
        ]

        # Geographic + subject facets (legacy Region / Country / Subjects browse).
        region_counts = dict(
            base.filter(regions__isnull=False)
            .values_list("regions__slug")
            .annotate(c=Count("id", distinct=True))
            .values_list("regions__slug", "c")
        )
        ctx["region_facets"] = [
            {"slug": r.slug, "name": r.name, "count": region_counts.get(r.slug, 0)}
            for r in Region.objects.all()
            if region_counts.get(r.slug, 0)
        ]
        country_counts = (
            base.exclude(country="")
            .values_list("country")
            .annotate(c=Count("id"))
            .order_by("country")
        )
        ctx["country_facets"] = [
            {"value": value, "count": c} for value, c in country_counts
        ]
        subject_counts = (
            base.filter(keywords__status=Keyword.Status.APPROVED)
            .values_list("keywords__slug", "keywords__label", "keywords__kind")
            .annotate(c=Count("id", distinct=True))
            .order_by("-c")[:40]
        )
        ctx["subject_facets"] = [
            {"slug": slug, "label": label, "kind": kind, "count": c}
            for slug, label, kind, c in subject_counts
        ]
        conference_counts = (
            base.exclude(conference_name="")
            .values_list("conference_name")
            .annotate(c=Count("id"))
            .order_by("conference_name")
        )
        ctx["conference_facets"] = [
            {"value": value, "count": c} for value, c in conference_counts
        ]

        # Multi-select state for templates (allows {% if value in selected_types %}).
        selected_types = set(self.request.GET.getlist("type"))
        selected_cols = set(self.request.GET.getlist("collection"))
        selected_regions = set(self.request.GET.getlist("region"))
        selected_countries = set(self.request.GET.getlist("country"))
        selected_subjects = set(self.request.GET.getlist("subject"))
        selected_conferences = set(self.request.GET.getlist("conference"))
        selected_publishers = set(self.request.GET.getlist("publisher"))
        ctx["selected_types"] = selected_types
        ctx["selected_collections"] = selected_cols
        ctx["selected_regions"] = selected_regions
        ctx["selected_countries"] = selected_countries
        ctx["selected_subjects"] = selected_subjects
        ctx["selected_conferences"] = selected_conferences
        ctx["selected_publishers"] = selected_publishers
        ctx["total_docs"] = base.count()
        ctx["oa_docs"] = base.filter(visibility=Document.Visibility.PUBLIC_OPEN).count()
        ctx["university_count"] = (
            Institution.objects
            .filter(repository_authors__documents__in=base, is_active=True)
            .distinct()
            .count()
        )
        ctx["has_active_filters"] = bool(
            selected_types
            or selected_cols
            or selected_regions
            or selected_countries
            or selected_subjects
            or selected_conferences
            or selected_publishers
            or self.request.GET.get("year")
            or self.request.GET.get("oa")
        )
        ctx["view"] = "grid" if self.request.GET.get("view") == "grid" else "list"

        # FRREP-SR012 — Browse's filter-panel usage was invisible to search
        # analytics because only Search/Advanced called track_search_event.
        # Best-effort + only when a facet is actually active, so plain
        # unfiltered browsing doesn't flood the search audit trail.
        # FRREP-AR011 — thread the SearchEvent id onto result links (same as
        # Search/Advanced) so a click-through from a filtered Browse result
        # counts toward the click-through-rate panel too.
        ctx["search_event_id"] = None
        if ctx["has_active_filters"]:
            try:
                from apps.repository.search.services import track_search_event

                active_filters = {
                    "type": sorted(selected_types),
                    "collection": sorted(selected_cols),
                    "region": sorted(selected_regions),
                    "country": sorted(selected_countries),
                    "subject": sorted(selected_subjects),
                    "conference": sorted(selected_conferences),
                    "publisher": sorted(selected_publishers),
                    "year": self.request.GET.get("year") or "",
                    "oa": self.request.GET.get("oa") or "",
                }
                active_filters = {k: v for k, v in active_filters.items() if v}
                ctx["search_event_id"] = track_search_event(
                    query="",
                    filters=active_filters,
                    result_count=ctx["paginator"].count if ctx.get("paginator") else 0,
                    user=self.request.user,
                    request=self.request,
                )
            except Exception:  # noqa: BLE001 — analytics must never break Browse
                pass
        return ctx

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["documents/partials/document_list_results.html"]
        return [self.template_name]


# ── Browse-by index pages (legacy "Browse by region / country / subject") ───


class _BrowseIndexView(TemplateView):
    """Shared base for the browse-by-facet landing pages.

    Each subclass lists the values of one facet with live document counts; every
    row links into :class:`BrowseView` pre-filtered on that value, so the index
    pages and the filter rail share one results surface.
    """

    template_name = "documents/browse_index.html"
    active_browse = ""          # which rail link is current
    index_kicker = "RUFORUM · Browse"
    index_title = ""
    index_intro = ""
    filter_param = ""           # query param the rows filter on

    def build_items(self, base):  # pragma: no cover - overridden
        raise NotImplementedError

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        base = Document.objects.for_user(self.request.user)
        ctx["active_browse"] = self.active_browse
        ctx["index_kicker"] = self.index_kicker
        ctx["index_title"] = self.index_title
        ctx["index_intro"] = self.index_intro
        groups = self.build_items(base)
        # Emphasis tiers: the document count drives a "depth" signal so deep
        # collections read louder than the long tail of singletons. Tier is
        # relative to each group's own leader (3 = deep, 2 = mid, 1 = quiet).
        leader = None
        for g in groups:
            mx = max((it["count"] for it in g["items"]), default=0)
            for it in g["items"]:
                ratio = (it["count"] / mx) if mx else 0
                it["tier"] = 3 if ratio >= 0.5 else 2 if ratio >= 0.12 else 1
                if leader is None or it["count"] > leader["count"]:
                    leader = it
        ctx["groups"] = groups
        ctx["total_in_index"] = sum(
            item["count"] for g in groups for item in g["items"]
        )
        ctx["leader"] = leader
        ctx["facet_count"] = sum(len(g["items"]) for g in groups)
        ctx["index_noun"] = {
            "region": "regions",
            "country": "countries",
            "subject": "subjects",
            "conference": "conferences",
            "institution": "institutions",
        }.get(self.active_browse, "entries")
        return ctx


class BrowseByRegionView(_BrowseIndexView):
    active_browse = "region"
    index_title = "Browse by region"
    index_intro = (
        "Resources grouped by their geographic region of focus. A resource may "
        "be tagged with more than one region, so totals can overlap."
    )

    def build_items(self, base):
        counts = dict(
            base.filter(regions__isnull=False)
            .values_list("regions__slug")
            .annotate(c=Count("id", distinct=True))
            .values_list("regions__slug", "c")
        )
        items = [
            {
                "label": r.name,
                "count": counts.get(r.slug, 0),
                "url": f"{reverse('repo_documents:browse')}?region={r.slug}",
            }
            for r in Region.objects.all()
            if counts.get(r.slug, 0)
        ]
        return [{"label": "", "items": items}] if items else []


class BrowseByCountryView(_BrowseIndexView):
    active_browse = "country"
    index_title = "Browse by country"
    index_intro = "Resources grouped by the country they relate to."

    def build_items(self, base):
        rows = (
            base.exclude(country="")
            .values_list("country")
            .annotate(c=Count("id"))
            .order_by("country")
        )
        items = [
            {
                "label": value,
                "count": c,
                "url": f"{reverse('repo_documents:browse')}?country={value}",
            }
            for value, c in rows
        ]
        return [{"label": "", "items": items}] if items else []


class BrowseBySubjectView(_BrowseIndexView):
    active_browse = "subject"
    index_title = "Browse by subject"
    index_intro = (
        "Browse by broad AGRIS subject categories or by more specific AGROVOC "
        "terms — or search on any keyword."
    )

    def build_items(self, base):
        from apps.repository.documents.models import Keyword

        groups = []
        for kind, label in (
            (Keyword.Kind.AGRIS_CATEGORY, "AGRIS subject categories"),
            (Keyword.Kind.AGROVOC, "AGROVOC terms"),
            (Keyword.Kind.ADDITIONAL, "Additional keywords"),
            (Keyword.Kind.GENERAL, "Keywords"),
        ):
            rows = (
                base.filter(
                    keywords__status=Keyword.Status.APPROVED, keywords__kind=kind
                )
                .values_list("keywords__slug", "keywords__label")
                .annotate(c=Count("id", distinct=True))
                .order_by("-c", "keywords__label")
            )
            items = [
                {
                    "label": kw_label,
                    "count": c,
                    "url": f"{reverse('repo_documents:browse')}?subject={slug}",
                }
                for slug, kw_label, c in rows
            ]
            if items:
                groups.append({"label": label, "items": items})
        return groups


class BrowseByConferenceView(_BrowseIndexView):
    active_browse = "conference"
    index_title = "Browse by conference"
    index_intro = (
        "Conference papers, posters and presentations grouped by the meeting they "
        "were presented at — RUFORUM conferences and workshops as well as other "
        "events attended by the network."
    )

    def build_items(self, base):
        from django.utils.http import urlencode

        rows = (
            base.exclude(conference_name="")
            .values_list("conference_name")
            .annotate(c=Count("id", distinct=True))
            .order_by("conference_name")
        )
        items = [
            {
                "label": name,
                "count": c,
                "url": f"{reverse('repo_documents:browse')}?{urlencode({'conference': name})}",
            }
            for name, c in rows
        ]
        return [{"label": "", "items": items}] if items else []


class BrowseByInstitutionView(_BrowseIndexView):
    active_browse = "institution"
    index_title = "Browse by institution"
    index_intro = (
        "Resources grouped by their publisher or issuing institution. In some "
        "cases the institution is a corporate author; in others, a publisher."
    )

    def build_items(self, base):
        from django.utils.http import urlencode

        rows = (
            base.exclude(publisher="")
            .values_list("publisher")
            .annotate(c=Count("id", distinct=True))
            .order_by("publisher")
        )
        items = [
            {
                "label": name,
                "count": c,
                "url": f"{reverse('repo_documents:browse')}?{urlencode({'publisher': name})}",
            }
            for name, c in rows
        ]
        return [{"label": "", "items": items}] if items else []


class BrowseByAuthorView(ListView):
    """Paginated A–Z author index (legacy 'Browse by Authors')."""

    template_name = "documents/browse_authors.html"
    paginate_by = 60
    context_object_name = "authors"

    def get_queryset(self):
        qs = (
            Author.objects.annotate(
                doc_count=Count("documents", filter=Q(documents__status="published"), distinct=True)
            )
            .filter(doc_count__gt=0)
            .order_by("last_name", "first_name")
        )
        q = self.request.GET.get("q", "").strip()
        if q:
            qs = qs.filter(Q(last_name__icontains=q) | Q(first_name__icontains=q))
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["active_browse"] = "author"
        ctx["query"] = self.request.GET.get("q", "")
        ctx["total_authors"] = (
            Author.objects.annotate(
                dc=Count("documents", filter=Q(documents__status="published"), distinct=True)
            )
            .filter(dc__gt=0)
            .count()
        )
        return ctx


class DocumentDetailView(DetailView):
    model = Document
    template_name = "documents/document_detail.html"
    slug_field = "document_uid"
    slug_url_kwarg = "uid"

    def dispatch(self, request, *args, **kwargs):
        # FRREP-SR009 — an anonymous visitor hitting a gated document used to
        # dead-end on a blanket "Access denied" 403 with no path forward.
        # Send them to sign in (with a `next` back to this document) instead;
        # authenticated-but-unauthorised users still get the normal 403 from
        # get_object() below.
        if not request.user.is_authenticated:
            obj = get_object_or_404(Document, document_uid=kwargs["uid"])
            if not can_access(request.user, obj):
                return redirect_to_login(request.get_full_path())
        return super().dispatch(request, *args, **kwargs)

    def get_object(self, queryset=None):
        obj = get_object_or_404(Document, document_uid=self.kwargs["uid"])
        if not can_access(self.request.user, obj):
            services.log_access_violation(self.request, obj, action="view")
            raise PermissionDenied
        return obj

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        document = self.object
        ctx["can_download"] = can_download(self.request.user, document)
        ctx["versions"] = document.versions.order_by("-uploaded_at")
        top_level_comments = list(
            document.comments.filter(parent__isnull=True)
            .select_related("author")
            .prefetch_related("replies__author")
        )
        ctx["comments"] = top_level_comments
        # FRREP-COL003 — group the discussion by section_anchor (a blank
        # anchor buckets into "General") instead of one flat chronological
        # list, so readers can jump to the part of the document a thread is
        # actually about.
        anchor_order: list[str] = []
        anchor_groups: dict[str, list] = {}
        for comment in top_level_comments:
            key = comment.section_anchor or "General"
            if key not in anchor_groups:
                anchor_groups[key] = []
                anchor_order.append(key)
        if "General" in anchor_order:
            anchor_order.remove("General")
            anchor_order.insert(0, "General")
        for comment in top_level_comments:
            key = comment.section_anchor or "General"
            anchor_groups[key].append(comment)
        ctx["comment_groups"] = [
            {"anchor": key, "comments": anchor_groups[key]} for key in anchor_order
        ]
        ctx["access_request_form"] = AccessRequestForm()
        ctx["share_form"] = ShareForm()
        ctx["comment_form"] = CommentForm()
        # FRREP-COL004 — who may resolve/reopen a comment thread on this document.
        can_manage = bool(
            self.request.user.is_authenticated and _can_manage_comment(self.request.user, document)
        )
        ctx["can_manage_comments"] = can_manage
        # FRREP-VL007 — reflect whether the signed-in user has bookmarked this
        # document, so the action bar can render Save vs. Saved.
        ctx["is_bookmarked"] = bool(
            self.request.user.is_authenticated
            and document.bookmarks.filter(user=self.request.user).exists()
        )
        # FRREP-AR007/AR008 — surface the AI classification + metadata-extraction
        # insights (SUMMARY is already handled above via document.ai_summary) so
        # a manager can accept or override them. Only shown to users who can
        # manage the document, and only while still pending a human decision.
        ctx["ai_classification"] = None
        ctx["ai_classification_suggested_collection"] = None
        ctx["ai_metadata_insight"] = None
        if can_manage:
            from apps.repository.analytics.models import AIDocumentInsight

            ctx["ai_classification"] = (
                document.ai_insights.filter(kind=AIDocumentInsight.Kind.CLASSIFICATION)
                .order_by("-generated_at")
                .first()
            )
            # FRREP-AR007 — resolve the raw slug the AI chain suggests into a
            # human-readable Collection name; the template must never render
            # ``suggested_collection_slug`` directly (that leaked the slug to
            # users, e.g. "ruforum-annual-reports-strategic-plans...").
            if ctx["ai_classification"] is not None:
                suggested_slug = (ctx["ai_classification"].payload or {}).get(
                    "suggested_collection_slug", ""
                )
                if suggested_slug:
                    ctx["ai_classification_suggested_collection"] = (
                        Collection.objects.filter(slug=suggested_slug).first()
                    )
            ctx["ai_metadata_insight"] = (
                document.ai_insights.filter(kind=AIDocumentInsight.Kind.METADATA)
                .order_by("-generated_at")
                .first()
            )
        # FRREP-MCL009 — resolve the linked RIMS grant (if any) so the Funding
        # section can show its title and, for users who can read RIMS funding,
        # deep-link to the record.
        if document.grant_reference:
            from apps.repository.integration.grant_link import lookup_grant

            fr = lookup_grant(document.grant_reference)
            if fr is not None:
                user = self.request.user
                can_view = user.is_authenticated and getattr(user, "role", None) in (
                    UserRole.ADMIN,
                    UserRole.SYSTEM_ADMIN,
                    UserRole.GRANTS_MANAGER,
                    UserRole.PROGRAM_DIRECTOR,
                    UserRole.FINANCE_DIRECTOR,
                )
                ctx["linked_grant"] = {
                    "public_grant_id": fr.public_grant_id,
                    "title": fr.title,
                    "pk": fr.pk,
                    "can_view": can_view,
                }
        # Reach metrics for the metadata sidebar (best-effort).
        try:
            from apps.repository.analytics.services import get_per_document_stats

            ctx["stats"] = get_per_document_stats(document)
        except Exception:  # pragma: no cover
            ctx["stats"] = None
        return ctx

    def get(self, request, *args, **kwargs):
        response = super().get(request, *args, **kwargs)
        # Best-effort access logging (analytics).
        try:
            from apps.repository.analytics.services import (
                record_access_event,
                record_search_click,
            )

            record_access_event(self.object, request.user, request)
            # FRREP-AR011 — a `?se=<SearchEvent.pk>` on the incoming link means
            # this visit followed a search result; attribute the click-through
            # back to that search so the conversion-rate panel isn't stuck at 0%.
            se = request.GET.get("se")
            if se:
                record_search_click(se, self.object)
        except Exception:  # pragma: no cover
            pass
        return response


class OERAccessRedirectView(View):
    """FRREP-INT006 — tracked hop for a REP lesson's "Open" OER link.

    Previously the lesson template linked straight at the Repository file
    with zero click-tracking, so REP-driven reach was invisible to Repository
    analytics. This logs an ``AccessEvent(source="rep")`` first, then sends
    the reader on to the actual file (or the document page, if the caller
    can't download it directly).
    """

    def get(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        if not can_access(request.user, document):
            if not request.user.is_authenticated:
                return redirect_to_login(request.get_full_path())
            raise PermissionDenied
        try:
            from apps.repository.analytics.models import AccessEvent
            from apps.repository.analytics.services import record_access_event

            record_access_event(
                document, request.user, request, source=AccessEvent.Source.REP
            )
        except Exception:  # pragma: no cover — tracking must never block the redirect
            pass
        if can_download(request.user, document) and document.file:
            return redirect(document.file.url)
        return redirect(document_detail_url(document))


# ── Staged uploads (wizard background-upload) ─────────────────────────────
#
# The submit wizard (Phase 2.1 UX) ships the source file to the server as soon
# as the user picks it on stage 1, while they're still filling out metadata.
# Files land under MEDIA_ROOT/staged_uploads/<token>/ and are tracked in the
# session. The final form posts the token instead of re-uploading the file —
# submission feels instant. On a successful submission the staged copy is
# removed; orphaned entries linger until the session expires (acceptable for
# now — cleanup job is a follow-up).

STAGED_SESSION_KEY = "repo_staged_uploads"
STAGED_DIR = "staged_uploads"


def _staged_store(request) -> dict[str, dict[str, Any]]:
    return request.session.get(STAGED_SESSION_KEY) or {}


def _resolve_staged_upload(request, token: str):
    """Return (DjangoFile, entry) for `token`, or None if missing/expired."""
    entry = _staged_store(request).get(token or "")
    if not entry:
        return None
    abs_path = Path(settings.MEDIA_ROOT) / entry["rel_path"]
    if not abs_path.exists():
        return None
    fh = abs_path.open("rb")
    f = DjangoFile(fh, name=entry["name"])
    return f, entry


def _discard_staged_upload(request, token: str) -> None:
    store = _staged_store(request)
    entry = store.pop(token or "", None)
    if entry:
        try:
            abs_path = Path(settings.MEDIA_ROOT) / entry["rel_path"]
            if abs_path.exists():
                abs_path.unlink()
            parent = abs_path.parent
            if parent.exists() and not any(parent.iterdir()):
                parent.rmdir()
        except OSError:
            pass
    request.session[STAGED_SESSION_KEY] = store
    request.session.modified = True


class StagedUploadView(LoginRequiredMixin, View):
    """POST: stage a file. DELETE: discard a staged file."""

    http_method_names = ["post", "delete"]

    def post(self, request, *args, **kwargs):
        upload = request.FILES.get("file")
        if not upload:
            return JsonResponse({"error": "missing-file"}, status=400)
        try:
            services._validate_repository_upload(upload)
        except ValidationError as exc:
            msg = exc.message if hasattr(exc, "message") else str(exc)
            return JsonResponse({"error": msg}, status=400)

        token = uuid.uuid4().hex
        safe_name = get_valid_filename(upload.name) or "upload"
        rel_dir = Path(STAGED_DIR) / token
        abs_dir = Path(settings.MEDIA_ROOT) / rel_dir
        abs_dir.mkdir(parents=True, exist_ok=True)
        rel_path = rel_dir / safe_name
        abs_path = Path(settings.MEDIA_ROOT) / rel_path
        with abs_path.open("wb") as fh:
            for chunk in upload.chunks():
                fh.write(chunk)

        store = request.session.get(STAGED_SESSION_KEY) or {}
        store[token] = {
            "rel_path": str(rel_path),
            "name": upload.name,
            "size": upload.size,
            "content_type": upload.content_type or "",
        }
        request.session[STAGED_SESSION_KEY] = store
        request.session.modified = True

        # FRREP-DCI009 — read embedded title/author/date so the wizard can
        # pre-populate the bibliographic form as *editable suggestions*.
        metadata = services.extract_file_metadata(abs_path, filename=upload.name)
        return JsonResponse(
            {
                "token": token,
                "name": upload.name,
                "size": upload.size,
                "metadata": metadata,
            }
        )

    def delete(self, request, *args, **kwargs):
        try:
            payload = json.loads(request.body.decode("utf-8") or "{}")
        except (json.JSONDecodeError, UnicodeDecodeError):
            payload = {}
        token = (payload.get("token") or request.GET.get("token") or "").strip()
        if token:
            _discard_staged_upload(request, token)
        return JsonResponse({"ok": True})


# ---------------------------------------------------------------------------
# Resumable / chunked uploads (FRREP-DCI004)
# ---------------------------------------------------------------------------


def _register_resumable_in_session(request, token: str, entry: dict) -> None:
    store = request.session.get(STAGED_SESSION_KEY) or {}
    store[token] = entry
    request.session[STAGED_SESSION_KEY] = store
    request.session.modified = True


class ResumableStartView(LoginRequiredMixin, View):
    """POST: open a new resumable upload session."""

    http_method_names = ["post"]

    def post(self, request, *args, **kwargs):
        try:
            payload = json.loads(request.body.decode("utf-8") or "{}")
        except (json.JSONDecodeError, UnicodeDecodeError):
            return JsonResponse({"error": "invalid-json"}, status=400)

        filename = (payload.get("filename") or "").strip()
        try:
            total_bytes = int(payload.get("total_bytes") or 0)
        except (TypeError, ValueError):
            return JsonResponse({"error": "invalid-total-bytes"}, status=400)
        chunk_size_in = payload.get("chunk_size")
        try:
            chunk_size = (
                int(chunk_size_in)
                if chunk_size_in is not None
                else services.RESUMABLE_CHUNK_SIZE_BYTES
            )
        except (TypeError, ValueError):
            return JsonResponse({"error": "invalid-chunk-size"}, status=400)

        if not filename:
            return JsonResponse({"error": "missing-filename"}, status=400)

        try:
            upload = services.start_resumable_upload(
                user=request.user,
                filename=filename,
                total_bytes=total_bytes,
                chunk_size=chunk_size,
            )
        except ValidationError as exc:
            msg = exc.message if hasattr(exc, "message") else str(exc)
            # Size-limit rejections deserve a specific HTTP code per FRREP-DCI006.
            status = 413 if "size exceeds" in msg.lower() else 400
            return JsonResponse({"error": msg}, status=status)

        return JsonResponse(
            {
                "token": upload.token,
                "filename": upload.filename,
                "chunk_size": upload.chunk_size,
                "expected_chunks": upload.expected_chunks,
                "expires_at": upload.expires_at.isoformat(),
            },
            status=201,
        )


class ResumableChunkView(LoginRequiredMixin, View):
    """PATCH: append one chunk to an existing session.

    Headers: ``X-Resumable-Token``, ``X-Resumable-Chunk-Index``.
    Body:    raw chunk bytes.
    """

    http_method_names = ["patch"]

    def patch(self, request, *args, **kwargs):
        token = request.headers.get("X-Resumable-Token", "").strip()
        index_raw = request.headers.get("X-Resumable-Chunk-Index", "")
        try:
            chunk_index = int(index_raw)
        except (TypeError, ValueError):
            return JsonResponse({"error": "invalid-chunk-index"}, status=400)

        if not token:
            return JsonResponse({"error": "missing-token"}, status=400)

        body = request.body or b""
        try:
            upload = services.append_resumable_chunk(
                token=token,
                user=request.user,
                chunk_index=chunk_index,
                body=body,
            )
        except services.ResumableUploadNotFound as exc:
            return JsonResponse({"error": str(exc)}, status=410)
        except ValidationError as exc:
            msg = exc.message if hasattr(exc, "message") else str(exc)
            return JsonResponse({"error": msg}, status=400)

        return JsonResponse(
            {
                "token": upload.token,
                "chunks_received": upload.chunks_received,
                "expected_chunks": upload.expected_chunks,
                "received_chunk_indexes": upload.received_chunk_indexes,
            }
        )


class ResumableFinalizeView(LoginRequiredMixin, View):
    """POST: assemble + validate the upload, register it in the staged session
    store under the same token, and return the staged-upload payload that
    ``DocumentSubmitView`` already consumes.
    """

    http_method_names = ["post"]

    def post(self, request, *args, **kwargs):
        try:
            payload = json.loads(request.body.decode("utf-8") or "{}")
        except (json.JSONDecodeError, UnicodeDecodeError):
            payload = {}

        token = (payload.get("token") or "").strip()
        expected_sha = (payload.get("sha256_hex") or "").strip().lower() or None
        if not token:
            return JsonResponse({"error": "missing-token"}, status=400)

        try:
            upload, entry = services.finalize_resumable_upload(
                token=token,
                user=request.user,
                expected_sha256=expected_sha,
            )
        except services.ResumableUploadNotFound as exc:
            return JsonResponse({"error": str(exc)}, status=410)
        except services.ResumableUploadHashMismatch as exc:
            return JsonResponse({"error": str(exc)}, status=422)
        except services.ResumableUploadValidationFailed as exc:
            return JsonResponse({"error": exc.message}, status=422)
        except ValidationError as exc:
            msg = exc.message if hasattr(exc, "message") else str(exc)
            return JsonResponse({"error": msg}, status=400)
        except ResumableUpload.DoesNotExist:
            return JsonResponse({"error": "Unknown resumable token."}, status=410)

        _register_resumable_in_session(request, token, entry)
        return JsonResponse(
            {
                "token": token,
                "name": entry["name"],
                "size": entry["size"],
                "sha256_hex": upload.sha256_hex,
            },
            status=201,
        )


class ResumableAbortView(LoginRequiredMixin, View):
    """DELETE: mark a resumable session ABANDONED and remove its chunk dir."""

    http_method_names = ["delete"]

    def delete(self, request, *args, **kwargs):
        try:
            payload = json.loads(request.body.decode("utf-8") or "{}")
        except (json.JSONDecodeError, UnicodeDecodeError):
            payload = {}
        token = (payload.get("token") or request.GET.get("token") or "").strip()
        if token:
            services.abort_resumable_upload(token=token, user=request.user)
        return JsonResponse({"ok": True})


# ── Wizard typeahead / suggestion endpoints (REPO-SP2) ──────────────────────


class AuthorSearchView(LoginRequiredMixin, View):
    """JSON typeahead over the Author Registry (FRREP-MCL001).

    GET ``?q=`` matches name / ORCID / email / institution and returns enough
    to show each candidate (ORCID, institution, affiliation) so the submitter
    picks the right person instead of re-typing — keeping the registry clean.
    """

    http_method_names = ["get"]

    def get(self, request, *args, **kwargs):
        q = (request.GET.get("q") or "").strip()
        qs = Author.objects.select_related("institution")
        if q:
            qs = qs.filter(
                Q(first_name__icontains=q)
                | Q(last_name__icontains=q)
                | Q(orcid__icontains=q)
                | Q(email__icontains=q)
                | Q(institution__name__icontains=q)
            )
        qs = qs.order_by("last_name", "first_name")[:20]
        results = [
            {
                "id": a.pk,
                "name": str(a),
                "full_name": a.full_name,
                "orcid": a.orcid or "",
                "institution": a.institution.name if a.institution_id else "",
                "affiliation": a.affiliation_text or "",
                "verified": bool(a.verified_at),
            }
            for a in qs
        ]
        return JsonResponse({"results": results})


class KeywordSearchView(LoginRequiredMixin, View):
    """JSON typeahead over APPROVED controlled-vocabulary keywords (FRREP-MCL007).

    Only approved terms surface for autocomplete; a free-typed new tag is still
    accepted by the wizard and routed to the moderation queue server-side.
    """

    http_method_names = ["get"]

    def get(self, request, *args, **kwargs):
        q = (request.GET.get("q") or "").strip()
        qs = Keyword.objects.filter(status=Keyword.Status.APPROVED)
        kind = (request.GET.get("kind") or "").strip()
        if kind:
            qs = qs.filter(kind=kind)
        if q:
            qs = qs.filter(label__icontains=q)
        qs = qs.order_by("label")[:20]
        results = [{"id": k.pk, "label": k.label, "kind": k.kind} for k in qs]
        return JsonResponse({"results": results})


class ClassificationSuggestView(LoginRequiredMixin, View):
    """Suggest a target collection from the auto-classification rules (FRREP-MCL006).

    GET ``?keywords=a,b&title=…&abstract=…&collection=<id>``. Returns a
    suggestion only when one fires *and* differs from the user's current pick,
    so the wizard surfaces it as an advisory "move to X?" the user can accept or
    ignore.
    """

    http_method_names = ["get"]

    def get(self, request, *args, **kwargs):
        labels = [
            t.strip() for t in (request.GET.get("keywords") or "").split(",") if t.strip()
        ]
        suggestion = services.evaluate_auto_classification(
            keywords=labels,
            title=request.GET.get("title") or "",
            abstract=request.GET.get("abstract") or "",
        )
        current = (request.GET.get("collection") or "").strip()
        if suggestion is None or (current and str(suggestion.pk) == current):
            return JsonResponse({"suggested": None})
        return JsonResponse(
            {
                "suggested": {
                    "id": suggestion.pk,
                    "slug": suggestion.slug,
                    "name": suggestion.name,
                }
            }
        )


class DocumentSubmitView(LoginRequiredMixin, CreateView):
    template_name = "documents/document_submit.html"
    form_class = DocumentSubmitForm
    model = Document

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        # ClearableFileInput / _MultiFileInput need request.FILES via form.files.
        return kwargs

    # FRREP-DCI002 — the batch report's "Retry failed files" link claimed the
    # shared metadata was kept, but nothing ever prefilled it. `_render_batch_report`
    # stashes a JSON-safe snapshot in the session; this restores it exactly once
    # (consumed on read) so a retry visit lands on a pre-populated form.
    _BULK_RETRY_SESSION_KEY = "repo_bulk_retry_metadata"

    def get_initial(self):
        initial = super().get_initial()
        stash = self.request.session.pop(self._BULK_RETRY_SESSION_KEY, None)
        if not stash:
            return initial
        for field in (
            "title", "abstract", "document_type", "license_type",
            "license_notes", "language", "doi", "grant_reference",
        ):
            if stash.get(field):
                initial[field] = stash[field]
        if stash.get("year"):
            initial["year"] = stash["year"]
        if stash.get("collection_id"):
            initial["collection"] = stash["collection_id"]
        author_ids = stash.get("author_ids") or []
        if author_ids:
            initial["authors_json"] = json.dumps([{"id": pk} for pk in author_ids])
        return initial

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        # FRREP-DCI011 — when a hash collision was detected and no disposition
        # has been chosen yet, hand the wizard the existing document so it can
        # render the cancel / new-version / separate-document choice.
        match = getattr(self, "_duplicate_match", None)
        if match is not None:
            ctx["duplicate_match"] = {
                "public_document_id": match.public_document_id,
                "title": match.title,
                "detail_url": document_detail_url(match),
            }
        return ctx

    # ── bulk-submission helpers (FRREP-DCI002/003/#8) ───────────────────────

    def _shared_metadata(self, cleaned, authors):
        """The metadata template shared by every file in the submission."""
        return {
            "title": cleaned["title"],
            "abstract": cleaned.get("abstract", ""),
            "document_type": cleaned["document_type"],
            "license_type": cleaned.get("license_type", Document.License.CC_BY),
            "license_notes": cleaned.get("license_notes", ""),
            "language": cleaned.get("language", "en"),
            "year": cleaned.get("year"),
            "doi": cleaned.get("doi", ""),
            "grant_reference": cleaned.get("grant_reference", ""),
            "collection": cleaned["collection"],
            "authors": authors,
        }

    def _effective_metadata(self, shared, shared_keywords, overrides):
        """Merge a file's per-file overrides (#8) onto the shared template.

        Only keys present *and* non-blank in ``overrides`` win; everything else
        inherits the shared value, so a per-file edit can never blank a mandatory
        field. Returns ``(meta, keyword_labels)``. Raises ``ValidationError`` for
        an unresolvable per-file grant.
        """
        meta = dict(shared)
        labels = list(shared_keywords)
        if not isinstance(overrides, dict) or not overrides:
            return meta, labels

        for key in (
            "title",
            "abstract",
            "document_type",
            "license_type",
            "license_notes",
            "language",
            "doi",
        ):
            val = overrides.get(key)
            if isinstance(val, str) and val.strip():
                meta[key] = val.strip()

        year = overrides.get("year")
        if year not in (None, "", []):
            try:
                meta["year"] = int(year)
            except (TypeError, ValueError):
                pass

        grant = overrides.get("grant_reference")
        if isinstance(grant, str) and grant.strip():
            from apps.repository.integration.grant_link import is_linkable_grant

            grant = grant.strip()
            if not is_linkable_grant(grant):
                raise ValidationError(
                    f"No active or completed RUFORUM grant matches “{grant}”."
                )
            meta["grant_reference"] = grant

        # Per-file author roster — registry payload first, free-text fallback.
        authors_json = (overrides.get("authors_json") or "").strip()
        authors_text = (overrides.get("authors_text") or "").strip()
        if authors_json:
            picked = services.resolve_authors_payload(authors_json)
            if picked:
                meta["authors"] = picked
        elif authors_text:
            picked = services.parse_authors_text(authors_text)
            if picked:
                meta["authors"] = picked

        if "keywords_text" in overrides:
            kw = overrides.get("keywords_text") or ""
            labels = [t.strip() for t in kw.split(",") if t.strip()]
        return meta, labels

    def _guard_required(self, meta):
        """Re-assert the mandatory metadata set on a file's *effective* metadata
        (FRREP-DCI010) — a per-file override must not produce an empty record."""
        for field_code in services.mandatory_metadata_fields(meta.get("collection")):
            value = meta.get(field_code)
            is_empty = (
                value.strip() == "" if isinstance(value, str) else value in (None, "")
            )
            if is_empty:
                raise ValidationError(f"Missing required field: {field_code}.")
        if not meta.get("authors"):
            raise ValidationError("At least one author is required.")

    def _ingest_one(self, f, meta, keyword_labels, suggested_slug, *, allow_duplicate, as_version):
        """Ingest a single file with its effective metadata. Returns
        ``("created"|"version", obj)``; raises ``DuplicateDocumentError`` /
        ``ValidationError`` for the caller to record as a failed row."""
        if as_version:
            existing = services.detect_duplicate(services.compute_file_hash(f))
            if existing is not None:
                services.add_version(
                    existing,
                    file=f,
                    changelog="Re-uploaded via the submit wizard as a new "
                    "version of an existing document.",
                    uploaded_by=self.request.user,
                    request=self.request,
                )
                return "version", existing
            # Not actually a duplicate after all — ingest it normally.
        self._guard_required(meta)
        document = services.ingest_document(
            file=f,
            title=meta["title"],
            collection=meta["collection"],
            document_type=meta["document_type"],
            abstract=meta.get("abstract", ""),
            license_type=meta.get("license_type", Document.License.CC_BY),
            license_notes=meta.get("license_notes", ""),
            language=meta.get("language", "en"),
            year=meta.get("year"),
            doi=meta.get("doi", ""),
            grant_reference=meta.get("grant_reference", ""),
            # Visibility is no longer collected on the submit wizard (#17) —
            # new docs inherit their collection's setting.
            visibility=Document.Visibility.INHERIT,
            authors=meta["authors"],
            submitted_by=self.request.user,
            source_system="manual",
            actor=self.request.user,
            request=self.request,
            allow_duplicate=allow_duplicate,
        )
        if keyword_labels:
            services.apply_keywords(
                document, keyword_labels, suggested_by=self.request.user
            )
        # FRREP-MCL006 / SRS A3 — the user was shown a classification suggestion
        # but kept a different collection: record the override.
        if suggested_slug and document.collection.slug != suggested_slug:
            services.log_classification_override(
                document,
                suggested_slug=suggested_slug,
                chosen_slug=document.collection.slug,
                actor=self.request.user,
                request=self.request,
            )
        services.submit_for_qa(document, actor=self.request.user, request=self.request)
        return "created", document

    def form_valid(self, form):
        cleaned = form.cleaned_data
        staged_token = (cleaned.get("staged_token") or "").strip()
        primary_file = cleaned.get("file")
        open_files: list = []
        if not primary_file and staged_token:
            resolved = _resolve_staged_upload(self.request, staged_token)
            if resolved is None:
                form.add_error(
                    "file",
                    "The uploaded file is no longer available — please re-upload.",
                )
                return self.form_invalid(form)
            primary_file, _entry = resolved
            open_files.append(primary_file)

        # FRREP-DCI011 — interactive duplicate disposition chosen in the wizard.
        decision = (cleaned.get("duplicate_decision") or "").strip()
        if decision == "cancel":
            for fh in open_files:
                fh.close()
            form.add_error(
                None,
                "Submission cancelled — no document was created. Upload a different "
                "file, or choose how to handle the duplicate.",
            )
            return self.form_invalid(form)
        allow_duplicate = decision == "separate"
        as_version = decision == "version"

        # Registry-first picker (FRREP-MCL001) posts a structured selection;
        # fall back to the legacy free-text block for the no-JS path.
        authors_json = (cleaned.get("authors_json") or "").strip()
        if authors_json:
            authors = services.resolve_authors_payload(authors_json)
        else:
            authors = services.parse_authors_text(cleaned.get("authors_text", ""))
        if not authors:
            for fh in open_files:
                fh.close()
            form.add_error(None, "At least one author is required.")
            return self.form_invalid(form)

        suggested_slug = (cleaned.get("suggested_collection_slug") or "").strip()
        shared = self._shared_metadata(cleaned, authors)
        shared_keywords = [
            t.strip()
            for t in (cleaned.get("keywords_text") or "").split(",")
            if t.strip()
        ]

        # Build the ordered list of submission items: the primary file (shared
        # metadata) plus every staged bulk file (FRREP-DCI002/003) with its own
        # per-file metadata override (#8), then any legacy no-JS additional files.
        items: list[tuple] = [(primary_file, getattr(primary_file, "name", "file"), None)]
        report_rows: list[dict] = []
        for entry in self._parse_bulk_payload(cleaned.get("bulk_files")):
            token = (entry.get("token") or "").strip()
            name = entry.get("name") or "file"
            resolved = _resolve_staged_upload(self.request, token) if token else None
            if resolved is None:
                report_rows.append(
                    {
                        "name": name,
                        "status": "error",
                        "reason": "The staged copy is no longer available — re-upload it.",
                    }
                )
                continue
            f, _entry = resolved
            open_files.append(f)
            items.append((f, name, entry.get("metadata") or {}))
        for f in list(cleaned.get("additional_files") or []):
            items.append((f, getattr(f, "name", "file"), None))

        is_bulk = len(items) > 1 or bool(report_rows)

        # FRREP-DCI011 — for a single-file submit, a hash collision surfaces the
        # interactive cancel / version / separate panel BEFORE anything is
        # created. A bulk submit keeps going and reports each duplicate as its
        # own row (never silently ingested), so one collision can't sink the run.
        if not is_bulk and not allow_duplicate and not as_version:
            existing = services.detect_duplicate(
                services.compute_file_hash(primary_file)
            )
            if existing is not None:
                self._duplicate_match = existing
                form.add_error(
                    None,
                    f"This file already exists as {existing.public_document_id}"
                    f" — “{existing.title[:80]}”. Choose how to proceed below.",
                )
                for fh in open_files:
                    fh.close()
                return self.form_invalid(form)

        documents: list[Document] = []
        versioned: list[Document] = []
        try:
            for f, name, overrides in items:
                try:
                    meta, labels = self._effective_metadata(
                        shared, shared_keywords, overrides
                    )
                    kind, obj = self._ingest_one(
                        f,
                        meta,
                        labels,
                        suggested_slug,
                        allow_duplicate=allow_duplicate,
                        as_version=as_version,
                    )
                except services.DuplicateDocumentError as exc:
                    if not is_bulk:
                        raise
                    dup = exc.duplicate_of
                    report_rows.append(
                        {
                            "name": name,
                            "status": "duplicate",
                            "document_id": dup.public_document_id,
                            "detail_url": document_detail_url(dup),
                            "reason": f"Already in the repository as {dup.public_document_id}.",
                        }
                    )
                    continue
                except services.PreCheckFailedError as exc:
                    # FRREP-QA001 — surface the exact pre-check failures back to
                    # the submitter instead of silently routing into manual QA.
                    if not is_bulk:
                        for message in exc.failures:
                            form.add_error(None, message)
                        return self.form_invalid(form)
                    report_rows.append(
                        {
                            "name": name,
                            "status": "error",
                            "reason": "; ".join(exc.failures),
                        }
                    )
                    continue
                except ValidationError as exc:
                    if not is_bulk:
                        raise
                    report_rows.append(
                        {
                            "name": name,
                            "status": "error",
                            "reason": exc.message if hasattr(exc, "message") else str(exc),
                        }
                    )
                    continue

                if kind == "version":
                    versioned.append(obj)
                    report_rows.append(
                        {
                            "name": name,
                            "status": "version",
                            "document_id": obj.public_document_id,
                            "detail_url": document_detail_url(obj),
                            "reason": f"Added as a new version of {obj.public_document_id}.",
                        }
                    )
                else:
                    documents.append(obj)
                    report_rows.append(
                        {
                            "name": name,
                            "status": "created",
                            "document_id": obj.public_document_id,
                            "detail_url": document_detail_url(obj),
                        }
                    )
        except ValidationError as exc:
            # Single-file path only — re-render the wizard with the error.
            form.add_error(None, exc.message if hasattr(exc, "message") else str(exc))
            return self.form_invalid(form)
        finally:
            for fh in open_files:
                fh.close()

        if not documents and not versioned and not is_bulk:
            form.add_error(None, "No document was created — please try again.")
            return self.form_invalid(form)

        # Discard the staged blobs for every file that was consumed.
        for tok in self._consumed_tokens(cleaned, staged_token):
            _discard_staged_upload(self.request, tok)

        # FRREP-MCL012 / SRS A4 — surface the deprecation warning collected by
        # the form when the submitter picked a deprecated Collection.
        deprecation_warning = getattr(form, "_deprecation_warning", None)
        if deprecation_warning:
            messages.warning(self.request, deprecation_warning)

        # FRREP-DCI002 — a bulk run always lands on the per-file batch report.
        if is_bulk:
            return self._render_batch_report(report_rows, shared)

        # ── single-file outcomes (Phase 1 behaviour, unchanged) ──────────────
        if versioned and not documents:
            messages.success(
                self.request,
                f"A new version was added to {versioned[0].public_document_id}.",
            )
            return redirect(document_detail_url(versioned[0]))

        messages.success(
            self.request,
            f"Submission received: {documents[0].public_document_id}.",
        )
        return redirect(document_detail_url(documents[0]))

    @staticmethod
    def _parse_bulk_payload(raw):
        """Decode the wizard's ``bulk_files`` JSON array; tolerate junk."""
        if not raw:
            return []
        try:
            data = json.loads(raw)
        except (json.JSONDecodeError, TypeError):
            return []
        return [e for e in data if isinstance(e, dict)] if isinstance(data, list) else []

    def _consumed_tokens(self, cleaned, staged_token):
        tokens = [staged_token] if staged_token else []
        for entry in self._parse_bulk_payload(cleaned.get("bulk_files")):
            tok = (entry.get("token") or "").strip()
            if tok:
                tokens.append(tok)
        return tokens

    def _render_batch_report(self, rows, shared=None):
        created = [r for r in rows if r["status"] == "created"]
        versioned = [r for r in rows if r["status"] == "version"]
        failed = [r for r in rows if r["status"] in ("error", "duplicate")]
        if failed and shared:
            # FRREP-DCI002 — stash a JSON-safe snapshot of the shared metadata
            # so "Retry failed files" actually restores it (DocumentSubmitView
            # .get_initial() consumes this on the next GET to the submit page).
            self.request.session[self._BULK_RETRY_SESSION_KEY] = {
                "title": shared.get("title", ""),
                "abstract": shared.get("abstract", ""),
                "document_type": shared.get("document_type", ""),
                "license_type": shared.get("license_type", ""),
                "license_notes": shared.get("license_notes", ""),
                "language": shared.get("language", "en"),
                "year": shared.get("year"),
                "doi": shared.get("doi", ""),
                "grant_reference": shared.get("grant_reference", ""),
                "collection_id": shared["collection"].pk if shared.get("collection") else None,
                "author_ids": [a.pk for a in shared.get("authors") or []],
            }
        return render(
            self.request,
            "documents/bulk_submit_report.html",
            {
                "rows": rows,
                "created": created,
                "versioned": versioned,
                "failed": failed,
                "total": len(rows),
                "submit_url": reverse("repo_documents:submit"),
                "browse_url": reverse("repo_documents:browse"),
            },
        )

_DOCUMENT_EDIT_WIZARD_STEPS = [
    {"n": 1, "title": "Bibliographic"},
    {"n": 2, "title": "Authors"},
    {"n": 3, "title": "Licence & visibility"},
    {"n": 4, "title": "Funding"},
]


class DocumentEditView(LoginRequiredMixin, UpdateView):
    model = Document
    form_class = DocumentMetadataEditForm
    template_name = "documents/document_edit.html"
    slug_field = "document_uid"
    slug_url_kwarg = "uid"

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        u = self.request.user
        if not (u.is_superuser or u.has_perm("repository_documents.change_document", obj) or obj.submitted_by_id == u.pk):
            raise PermissionDenied
        return obj

    def _can_set_visibility(self, document) -> bool:
        """FRREP-AC003 — only a collection manager / repository admin may
        change a document's visibility; the submitter cannot escalate their
        own document out of a restricted collection."""
        u = self.request.user
        if u.is_superuser or getattr(u, "role", None) in REPO_MANAGER_ROLES:
            return True
        return document.collection.managed_by.filter(pk=u.pk).exists()

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["can_set_visibility"] = self._can_set_visibility(self.object)
        return kwargs

    def get_initial(self):
        initial = super().get_initial()
        initial["edit_token"] = services.document_edit_token(self.object)
        initial["authors_text"] = services.render_authors_text(self.object)
        return initial

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _DOCUMENT_EDIT_WIZARD_STEPS
        ctx["cancel_url"] = document_detail_url(self.object)
        ctx["can_set_visibility"] = self._can_set_visibility(self.object)
        # FRREP-QA006 — the revise-and-resubmit loop: show the reviewer's
        # actual comments (not just "revision required") and let the submitter
        # know saving will resubmit automatically.
        needs_revision = self.object.status == Document.Status.REVISION_REQUIRED
        ctx["needs_revision"] = needs_revision
        ctx["submit_label"] = "Save & resubmit for QA" if needs_revision else "Save changes"
        if needs_revision:
            latest = (
                self.object.qa_records.filter(decision=QARecord.Decision.REVISION)
                .order_by("-decided_at")
                .first()
            )
            ctx["latest_revision_comments"] = latest.comments if latest else ""
        # FRREP-MCL009 — seed the JS grant-search widget with the currently
        # linked grant's title (grant_reference only stores the bare id).
        if self.object.grant_reference:
            from apps.repository.integration.grant_link import lookup_grant

            grant = lookup_grant(self.object.grant_reference)
            ctx["current_grant"] = (
                {"id": self.object.grant_reference, "title": grant.title}
                if grant
                else {"id": self.object.grant_reference, "title": self.object.grant_reference}
            )
        return ctx

    def form_valid(self, form):
        # Authors are handled separately — exclude from the model-field update.
        non_meta = {"edit_token", "authors_text"}
        try:
            services.update_document_metadata(
                self.object,
                fields={
                    k: form.cleaned_data[k]
                    for k in form.changed_data
                    if k not in non_meta
                },
                expected_token=form.cleaned_data.get("edit_token", ""),
                actor=self.request.user,
                request=self.request,
            )
        except services.EditConflictError as exc:
            form.add_error(
                None,
                "This document was edited by someone else while you were working on it. "
                "Reload the page to see the latest version, then re-apply your changes.",
            )
            # Document.status is a protected FSMField — refresh_from_db() can't set it.
            self.object = Document.objects.get(pk=self.object.pk)
            # FRREP-COL008 — the rejected edit's data is simply discarded with
            # no recovery path; at least tell both editors a conflict happened.
            try:
                services.notify_edit_conflict(self.object, rejected_editor=self.request.user)
            except Exception:  # pragma: no cover — notifications must never block the response
                pass
            return self.form_invalid(form)
        except ValidationError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)

        if "authors_text" in form.changed_data:
            try:
                authors = services.parse_authors_text(form.cleaned_data["authors_text"])
                services.set_document_authors(
                    self.object,
                    authors,
                    actor=self.request.user,
                    request=self.request,
                )
            except ValidationError as exc:
                msg = exc.message if hasattr(exc, "message") else str(exc)
                form.add_error("authors_text", msg)
                return self.form_invalid(form)

        # FRREP-QA006 — a REVISION_REQUIRED document was a dead end: the
        # submitter could edit it, but nothing ever moved it back into QA.
        # Re-fetch status defensively (protected FSMField — never
        # .refresh_from_db(); update_document_metadata never touches status,
        # but this stays correct even if that changes).
        current_status = Document.objects.only("status").get(pk=self.object.pk).status
        if current_status == Document.Status.REVISION_REQUIRED:
            try:
                services.submit_for_qa(self.object, actor=self.request.user, request=self.request)
            except services.PreCheckFailedError as exc:
                messages.error(
                    self.request,
                    "Your changes were saved, but the document could not be resubmitted for QA: "
                    + " ".join(exc.failures),
                )
                return redirect(reverse("repo_documents:document_edit", kwargs={"uid": self.object.document_uid}))
            except ValidationError as exc:
                messages.error(self.request, f"Your changes were saved, but resubmission failed: {exc}")
            else:
                messages.success(self.request, "Document updated and resubmitted for QA review.")
                return redirect(document_detail_url(self.object))

        messages.success(self.request, "Document updated.")
        return redirect(document_detail_url(self.object))


# ── Inline reader + download (with watermark for restricted) ─────────────────


def _serve_document_file(request, document, *, as_attachment: bool, file=None):
    """Build a response that delivers a document file to the caller.

    Restricted PDFs are watermarked per-recipient, which needs the whole payload
    in memory. Everything else (open-access files, non-PDFs) is streamed from
    disk with ``FileResponse`` so large files don't balloon memory (#33).
    Caller is responsible for the permission check + analytics event.

    ``file`` defaults to ``document.file`` but may be a historical
    ``DocumentVersion.file`` — visibility/watermark rules key off the parent
    ``document`` either way, so previous-version downloads are gated and stamped
    identically to the current file (FRREP-AC007 / FRREP-VL003).
    """
    file = file if file is not None else document.file
    original_name = file.name.rsplit("/", 1)[-1]
    lower_name = original_name.lower()
    content_type, _ = mimetypes.guess_type(original_name)
    content_type = content_type or "application/octet-stream"

    # FRREP-AC007 — watermarking previously only fired for .pdf, so a
    # restricted-collection .docx downloaded unwatermarked. Both formats the
    # repository accepts for text documents are now stamped.
    needs_watermark = not document.is_open_access and lower_name.endswith((".pdf", ".docx"))
    if needs_watermark:
        with file.open("rb") as fh:
            payload = fh.read()
        if request.user.is_authenticated:
            user_name = request.user.get_full_name() or request.user.email
        else:
            user_name = "Public visitor"
        timestamp = timezone.now().strftime("%Y-%m-%d %H:%M UTC")
        if lower_name.endswith(".pdf"):
            payload = apply_watermark(payload, user_full_name=user_name, timestamp=timestamp)
        else:
            payload = apply_docx_watermark(payload, user_full_name=user_name, timestamp=timestamp)
        response = HttpResponse(payload, content_type=content_type)
        disposition = "attachment" if as_attachment else "inline"
        response["Content-Disposition"] = f'{disposition}; filename="{original_name}"'
        return response

    # Open-access / non-watermarked — stream straight off disk.
    return FileResponse(
        file.open("rb"),
        content_type=content_type,
        as_attachment=as_attachment,
        filename=original_name,
    )


class DocumentReadView(DetailView):
    """FRREP-SR007 — in-app inline reader.

    Renders an embedded viewer (PDF.js for PDFs; a graceful download fallback
    for other formats). Public-safe: relies on ``can_access`` / ``can_download``
    rather than a login wall. The PDF bytes themselves are served by
    ``DocumentRawView`` so visibility + watermark rules apply in one place.
    """

    model = Document
    template_name = "documents/document_read.html"
    slug_field = "document_uid"
    slug_url_kwarg = "uid"
    context_object_name = "document"

    def get_object(self, queryset=None):
        obj = get_object_or_404(Document, document_uid=self.kwargs["uid"])
        if not can_access(self.request.user, obj):
            services.log_access_violation(self.request, obj, action="view")
            raise PermissionDenied
        return obj

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        document = self.object
        can_dl = can_download(self.request.user, document)
        ctx["can_download"] = can_dl
        original_name = document.file.name.rsplit("/", 1)[-1] if document.file else ""
        is_pdf = original_name.lower().endswith(".pdf")
        if not can_dl:
            # User can see the record but not the bytes (restricted doc).
            ctx["reader_mode"] = "request_access"
        elif is_pdf:
            ctx["reader_mode"] = "pdf"
        else:
            ctx["reader_mode"] = "fallback"
        ctx["access_request_form"] = AccessRequestForm()
        return ctx

    def get(self, request, *args, **kwargs):
        response = super().get(request, *args, **kwargs)
        try:
            from apps.repository.analytics.services import record_access_event

            record_access_event(self.object, request.user, request)
        except Exception:  # pragma: no cover
            pass
        return response


def _document_access_denied_response(request, document, *, action="download"):
    """FRREP-AC006 — a direct unauthorised download/inline-view used to raise
    a bare ``PermissionDenied``, landing on the generic site-wide 403 page
    with no document context or way forward. Render a document-aware 403
    instead, with a "Request Access" link when the visitor is signed in.

    Also records the attempt as an ``AccessViolation`` (REPO-SP4) so
    Repository Administrators have a reviewable trail."""
    services.log_access_violation(request, document, action=action)
    return render(
        request,
        "documents/document_access_denied.html",
        {"document": document},
        status=403,
    )


class DocumentRawView(View):
    """Serve the raw document bytes *inline* for the embedded reader.

    Same permission gate as download (``can_download``) and the same watermark
    pipeline; only the Content-Disposition differs (``inline`` vs attachment).
    """

    def get(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        if not can_download(request.user, document):
            return _document_access_denied_response(request, document, action="read")
        return _serve_document_file(request, document, as_attachment=False)


class DocumentDownloadView(View):
    def get(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        if not can_download(request.user, document):
            return _document_access_denied_response(request, document)
        try:
            from apps.repository.analytics.services import record_download_event

            record_download_event(document, None, request.user, request)
        except Exception:  # pragma: no cover
            pass
        return _serve_document_file(request, document, as_attachment=True)


# ── QA workflow ─────────────────────────────────────────────────────────────


class QAQueueView(QAParticipantRequiredMixin, ListView):
    template_name = "documents/qa_queue.html"
    paginate_by = 50
    context_object_name = "qa_records"

    def get_queryset(self):
        qs = QARecord.objects.filter(decision=QARecord.Decision.PENDING).select_related(
            "document", "document__collection", "reviewer"
        )
        if not self.request.user.is_superuser and self.request.user.role == UserRole.REPO_MANAGER:
            qs = qs.filter(
                Q(reviewer=self.request.user)
                | Q(document__collection__managed_by=self.request.user)
            ).distinct()
        # SP6 two-tier review — a Reviewer works only their own assignments; a
        # Publication Assistant sweeps the whole pending queue for compliance.
        if not self.request.user.is_superuser and self.request.user.role == UserRole.REVIEWER:
            qs = qs.filter(reviewer=self.request.user)
        sort = self.request.GET.get("sort")
        if sort == "overdue":
            return qs.order_by("assigned_at")
        return qs.order_by("assigned_at")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        now = timezone.now()
        records = list(ctx.get("qa_records") or [])
        # FRREP-QA008 — flag QARecords past their collection's qa_turnaround_days.
        overdue_count = 0
        for record in records:
            cutoff_days = record.document.collection.qa_turnaround_days or 14
            age = (now - record.assigned_at).days
            record.age_days = age
            record.is_overdue = age > cutoff_days
            record.turnaround_days = cutoff_days
            if record.is_overdue:
                overdue_count += 1
        ctx["overdue_count"] = overdue_count
        return ctx


class QAReviewView(QAParticipantRequiredMixin, FormView):
    """SP6 review page — three role-scoped actions on one QARecord:

    * management tier (incl. Editor in Chief) POSTs here → final ``decide_qa``;
    * the assigned Reviewer POSTs to ``QARecommendView`` → ``recommend_qa``;
    * a Publication Assistant POSTs to ``QAComplianceView`` →
      ``record_compliance_check``.
    The template renders whichever form(s) the visitor's flags allow.
    """

    template_name = "documents/qa_review.html"

    def dispatch(self, request, *args, **kwargs):
        self.qa_record = get_object_or_404(QARecord, pk=self.kwargs["pk"])
        self.role_flags = _qa_role_flags(request.user, self.qa_record)
        # A Reviewer may only open records assigned to them — mirrors the
        # queue scoping so a guessed pk doesn't widen their view.
        if (
            not request.user.is_superuser
            and getattr(request.user, "role", "") == UserRole.REVIEWER
            and self.qa_record.reviewer_id != request.user.pk
        ):
            raise PermissionDenied
        # Only the management tier may POST the final decision here. Enforce it
        # at dispatch (not just in form_valid) so an unauthorised finalize POST
        # returns a clean 403 regardless of whether the form is valid — a
        # non-finalizer POSTing an *invalid* form would otherwise fall through
        # to form_invalid and render a misleading 200.
        if request.method == "POST" and not self.role_flags["can_finalize"]:
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

    def get_form_class(self):
        return QAReviewForm if self.role_flags["can_finalize"] else QARecommendForm

    def get_initial(self):
        # Seed the rubric from the reviewer's scoring so the Editor in Chief
        # confirms rather than re-keys the six criteria.
        initial = super().get_initial()
        for code, _ in QARecord.CHECKLIST_CRITERIA:
            if self.qa_record.checklist.get(code):
                initial[f"checklist_{code}"] = True
        return initial

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["qa_record"] = self.qa_record
        ctx["document"] = self.qa_record.document
        ctx.update(self.role_flags)
        return ctx

    def form_valid(self, form):
        if not self.role_flags["can_finalize"]:
            # Reviewers submit through QARecommendView; a direct POST here is
            # an attempt to take the final decision without the authority.
            raise PermissionDenied
        try:
            services.decide_qa(
                self.qa_record,
                decision=form.cleaned_data["decision"],
                comments=form.cleaned_data.get("comments", ""),
                checklist=form.checklist_dict(),
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        messages.success(self.request, "QA decision recorded.")
        return redirect(reverse("repo_documents:qa_queue"))


class QARecommendView(QAParticipantRequiredMixin, FormView):
    """SP6 — the assigned Reviewer's recommendation (no publish authority)."""

    template_name = "documents/qa_review.html"
    form_class = QARecommendForm

    def dispatch(self, request, *args, **kwargs):
        self.qa_record = get_object_or_404(QARecord, pk=self.kwargs["pk"])
        flags = _qa_role_flags(request.user, self.qa_record)
        # Assigned reviewer or a finalizer (a manager may record a
        # recommendation on someone's behalf, e.g. from an email review).
        if not (flags["can_recommend"] or flags["can_finalize"]):
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

    def get(self, request, *args, **kwargs):
        return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))

    def form_valid(self, form):
        try:
            services.recommend_qa(
                self.qa_record,
                recommendation=form.cleaned_data["decision"],
                comments=form.cleaned_data.get("comments", ""),
                checklist=form.checklist_dict(),
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            messages.error(self.request, "; ".join(exc.messages))
            return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))
        messages.success(
            self.request,
            "Recommendation recorded — the Editor in Chief has been notified for the final decision.",
        )
        return redirect(reverse("repo_documents:qa_queue"))

    def form_invalid(self, form):
        messages.error(
            self.request,
            "; ".join(e for errs in form.errors.values() for e in errs) or "Could not record recommendation.",
        )
        return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))


class QAComplianceView(QAParticipantRequiredMixin, FormView):
    """SP6 — Publication Assistant's licensing/open-access compliance check."""

    template_name = "documents/qa_review.html"
    form_class = QAComplianceForm

    def dispatch(self, request, *args, **kwargs):
        self.qa_record = get_object_or_404(QARecord, pk=self.kwargs["pk"])
        if not _qa_role_flags(request.user, self.qa_record)["can_compliance"]:
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

    def get(self, request, *args, **kwargs):
        return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))

    def form_valid(self, form):
        try:
            services.record_compliance_check(
                self.qa_record,
                status=form.cleaned_data["status"],
                notes=form.cleaned_data.get("notes", ""),
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            messages.error(self.request, "; ".join(exc.messages))
        else:
            messages.success(self.request, "Compliance check recorded.")
        return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))

    def form_invalid(self, form):
        messages.error(
            self.request,
            "; ".join(e for errs in form.errors.values() for e in errs) or "Could not record compliance check.",
        )
        return redirect(reverse("repo_documents:qa_review", kwargs={"pk": self.qa_record.pk}))


# ── Withdraw / Reinstate ────────────────────────────────────────────────────


class WithdrawDocumentView(WithdrawalAuthorityRequiredMixin, FormView):
    template_name = "documents/withdraw_form.html"
    form_class = WithdrawForm

    def dispatch(self, request, *args, **kwargs):
        self.document = get_object_or_404(Document, document_uid=kwargs["uid"])
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        return ctx

    def form_valid(self, form):
        services.withdraw_document(
            self.document,
            reason=form.cleaned_data["reason"],
            actor=self.request.user,
            request=self.request,
        )
        messages.warning(self.request, f"Document {self.document.public_document_id} withdrawn.")
        return redirect(document_detail_url(self.document))


class ReinstateDocumentView(WithdrawalAuthorityRequiredMixin, FormView):
    template_name = "documents/reinstate_form.html"
    form_class = ReinstateForm

    def dispatch(self, request, *args, **kwargs):
        self.document = get_object_or_404(Document, document_uid=kwargs["uid"])
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        return ctx

    def form_valid(self, form):
        try:
            services.reinstate_document(
                self.document,
                reason=form.cleaned_data.get("reason", ""),
                actor=self.request.user,
                request=self.request,
            )
        except Exception as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        services.publish_document(self.document, actor=self.request.user, request=self.request)
        messages.success(self.request, "Document reinstated and re-published.")
        return redirect(document_detail_url(self.document))


# ── Collections (admin) ─────────────────────────────────────────────────────


class CollectionListView(ListView):
    template_name = "documents/collection_list.html"
    paginate_by = 50
    context_object_name = "collections"

    def get_queryset(self):
        qs = Collection.objects.exclude(visibility=Collection.Visibility.INTERNAL)
        if self.request.user.is_authenticated and self.request.user.role in REPO_MANAGER_ROLES:
            qs = Collection.objects.all()
        return qs.annotate(num_documents=Count("documents", distinct=True)).order_by("name")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["view"] = "grid" if self.request.GET.get("view") == "grid" else "list"
        return ctx


class CollectionDetailView(DetailView):
    model = Collection
    template_name = "documents/collection_detail.html"

    def get(self, request, *args, **kwargs):
        # Honour slug aliases (FRREP-MCL012): if no Collection matches the slug,
        # check CollectionSlugAlias and 301-redirect to the canonical URL so
        # bookmarks survive renames and merges.
        slug = kwargs.get("slug", "")
        if slug and not Collection.objects.filter(slug=slug).exists():
            alias = (
                CollectionSlugAlias.objects.filter(old_slug=slug)
                .select_related("collection")
                .first()
            )
            if alias:
                from django.http import HttpResponsePermanentRedirect

                return HttpResponsePermanentRedirect(
                    reverse(
                        "repo_documents:collection_detail",
                        kwargs={"slug": alias.collection.slug},
                    )
                )
        return super().get(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        # FRREP-SR006 — collection→sub-collection→document drill-down. Surface
        # this collection's visible sub-collections (with document counts) so the
        # taxonomy tree can be navigated one level at a time, and expose the
        # parent for drilling back up.
        is_manager = (
            self.request.user.is_authenticated
            and self.request.user.role in REPO_MANAGER_ROLES
        )
        child_qs = self.object.children.all()
        if not is_manager:
            child_qs = child_qs.exclude(visibility=Collection.Visibility.INTERNAL)
        ctx["subcollections"] = child_qs.annotate(
            num_documents=Count("documents", distinct=True)
        ).order_by("name")
        ctx["parent_collection"] = self.object.parent

        # A parent collection often holds no documents of its own — the papers
        # live in its sub-collections. Include documents from descendant
        # collections so a parent view isn't misleadingly empty, while still
        # letting users drill into a specific child.
        descendant_ids = [self.object.pk, *self._descendant_ids(self.object)]
        documents = (
            Document.objects.for_user(self.request.user)
            .filter(collection_id__in=descendant_ids)
            .select_related("collection")
        )
        doc_page = paginate_qs(self.request, documents, per_page=25, param="doc_page")
        ctx["documents"] = doc_page
        ctx["doc_page"] = doc_page
        ctx["includes_descendants"] = len(descendant_ids) > 1
        return ctx

    @staticmethod
    def _descendant_ids(collection) -> list[int]:
        """Breadth-first collect ids of all nested sub-collections (bounded depth
        guards against accidental cycles in the taxonomy)."""
        ids: list[int] = []
        frontier = list(collection.children.values_list("pk", flat=True))
        depth = 0
        while frontier and depth < 8:
            ids.extend(frontier)
            frontier = list(
                Collection.objects.filter(parent_id__in=frontier).values_list(
                    "pk", flat=True
                )
            )
            depth += 1
        return ids


_COLLECTION_WIZARD_STEPS = [
    {"n": 1, "title": "Identity"},
    {"n": 2, "title": "Workflow & access"},
    {"n": 3, "title": "Retention"},
    {"n": 4, "title": "People"},
]


class CollectionCreateView(RepoManagerRequiredMixin, CreateView):
    model = Collection
    form_class = CollectionForm
    template_name = "documents/collection_form.html"

    def get_success_url(self):
        return reverse("repo_documents:collection_list")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _COLLECTION_WIZARD_STEPS
        ctx["cancel_url"] = reverse("repo_documents:collection_list")
        return ctx


# FRREP-QA012 — M2M fields can't be diffed via a plain `.values()` lookup
# (each related row joins into its own result row), so their "old" state is
# snapshotted separately, before the form's own `.save()` rewrites them.
_COLLECTION_M2M_FIELDS = {"managed_by", "upload_notification_recipients"}


def _collection_audit_value(value):
    """Normalise a cleaned-data value to something JSON-serialisable for audit."""
    if hasattr(value, "pk"):
        return value.pk
    return value


class CollectionUpdateView(RepoManagerRequiredMixin, UpdateView):
    model = Collection
    form_class = CollectionForm
    template_name = "documents/collection_form.html"

    def get_success_url(self):
        return reverse("repo_documents:collection_detail", kwargs={"slug": self.object.slug})

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _COLLECTION_WIZARD_STEPS
        ctx["cancel_url"] = reverse(
            "repo_documents:collection_detail", kwargs={"slug": self.object.slug}
        )
        return ctx

    def form_valid(self, form):
        # FRREP-QA012 — QA-policy edits (qa_workflow_enabled, qa_turnaround_days,
        # retention_period_days/action/notice_days, metadata_schema, …) went
        # through CollectionUpdateView with zero audit trail. Snapshot the old
        # values *before* the form's own save() overwrites them — by the time
        # `form_valid` runs, `_post_clean()` has already `setattr`'d the new
        # values onto `self.object`, so re-reading it would show the new state
        # (same trap `update_document_metadata` documents for Document edits).
        changed_fields = list(form.changed_data)
        scalar_fields = [f for f in changed_fields if f not in _COLLECTION_M2M_FIELDS]
        old_scalars = {}
        if self.object.pk and scalar_fields:
            old_scalars = dict(
                Collection.objects.filter(pk=self.object.pk)
                .values(*scalar_fields)
                .first()
                or {}
            )
        old_m2m = {
            field: list(getattr(self.object, field).values_list("pk", flat=True))
            for field in changed_fields
            if field in _COLLECTION_M2M_FIELDS
        }

        response = super().form_valid(form)

        if changed_fields:
            changes: dict[str, Any] = {}
            for field in scalar_fields:
                changes[field] = {
                    "old": _collection_audit_value(old_scalars.get(field)),
                    "new": _collection_audit_value(form.cleaned_data.get(field)),
                }
            for field, old_pks in old_m2m.items():
                new_value = form.cleaned_data.get(field)
                new_pks = (
                    list(new_value.values_list("pk", flat=True))
                    if hasattr(new_value, "values_list")
                    else _collection_audit_value(new_value)
                )
                changes[field] = {"old": old_pks, "new": new_pks}
            audit_repository(
                actor=self.request.user,
                action=action_update(),
                target_model="Collection",
                object_id=self.object.pk,
                object_repr=self.object.name,
                changes=changes,
                request=self.request,
            )
        return response


# ── Collection group access (FRREP-AC005) ──────────────────────────────────


def _collection_access_context(collection: Collection) -> dict:
    grants = (
        CollectionGroupAccess.objects.filter(collection=collection)
        .select_related("group", "granted_by")
        .order_by("group__name")
    )
    active_count = sum(1 for g in grants if g.is_active)
    expired_count = sum(1 for g in grants if not g.is_active)
    return {
        "collection": collection,
        "grants": grants,
        "active_count": active_count,
        "expired_count": expired_count,
        "permission_levels": CollectionGroupAccess.PermissionLevel.choices,
        "now": timezone.now(),
    }


def _render_access_table(request, collection: Collection):
    """Re-render the grants table partial for HTMX swaps."""
    from django.template.loader import render_to_string

    ctx = _collection_access_context(collection)
    html = render_to_string(
        "documents/partials/collection_access_table.html", ctx, request=request
    )
    response = HttpResponse(html)
    response["HX-Trigger"] = "collectionAccessUpdated"
    return response


class CollectionAccessView(RepoManagerRequiredMixin, FormView):
    """Collection-manager view: list active grants + add new ones (FRREP-AC005)."""

    template_name = "documents/collection_access.html"
    form_class = CollectionGroupAccessForm

    def dispatch(self, request, *args, **kwargs):
        self.collection = get_object_or_404(Collection, slug=kwargs["slug"])
        return super().dispatch(request, *args, **kwargs)

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["collection"] = self.collection
        return kwargs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx.update(_collection_access_context(self.collection))
        ctx["cancel_url"] = reverse(
            "repo_documents:collection_detail", kwargs={"slug": self.collection.slug}
        )
        return ctx

    def form_valid(self, form):
        try:
            services.grant_collection_access(
                self.collection,
                form.cleaned_data["group"],
                permission_level=form.cleaned_data["permission_level"],
                actor=self.request.user,
                request=self.request,
                expires_at=form.cleaned_data.get("expires_at"),
                note=form.cleaned_data.get("note", ""),
            )
        except ValidationError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        if self.request.headers.get("HX-Request"):
            return _render_access_table(self.request, self.collection)
        messages.success(
            self.request,
            f"Access granted: {form.cleaned_data['group'].name} → {self.collection.name}.",
        )
        return redirect(
            "repo_documents:collection_access", slug=self.collection.slug
        )


class CollectionAccessUpdateView(RepoManagerRequiredMixin, View):
    """In-place edit of an existing grant — POST only, HTMX-friendly."""

    def post(self, request, slug, pk):
        collection = get_object_or_404(Collection, slug=slug)
        grant = get_object_or_404(
            CollectionGroupAccess, pk=pk, collection=collection
        )
        level = request.POST.get("permission_level", grant.permission_level)
        expires_raw = request.POST.get("expires_at", "").strip()
        note = request.POST.get("note", grant.note)
        expires_at = grant.expires_at
        if expires_raw:
            from django.utils.dateparse import parse_datetime

            parsed = parse_datetime(expires_raw)
            if parsed is None:
                messages.error(request, "Invalid expiry datetime.")
                return redirect("repo_documents:collection_access", slug=slug)
            expires_at = parsed
        elif "expires_at" in request.POST:
            # explicitly cleared
            expires_at = None
        try:
            services.grant_collection_access(
                collection,
                grant.group,
                permission_level=level,
                actor=request.user,
                request=request,
                expires_at=expires_at,
                note=note,
            )
        except ValidationError as exc:
            messages.error(request, str(exc))
        if request.headers.get("HX-Request"):
            return _render_access_table(request, collection)
        messages.success(request, "Grant updated.")
        return redirect("repo_documents:collection_access", slug=slug)


class CollectionAccessRevokeView(RepoManagerRequiredMixin, View):
    """Revoke (delete) a CollectionGroupAccess grant — POST only."""

    def post(self, request, slug, pk):
        collection = get_object_or_404(Collection, slug=slug)
        grant = get_object_or_404(
            CollectionGroupAccess, pk=pk, collection=collection
        )
        group_name = grant.group.name
        services.revoke_collection_access(
            grant, actor=request.user, request=request
        )
        if request.headers.get("HX-Request"):
            return _render_access_table(request, collection)
        messages.success(
            request, f"Revoked access for {group_name} on {collection.name}."
        )
        return redirect("repo_documents:collection_access", slug=slug)


# ── User groups (FRREP-AC005 in-app group + member management) ─────────────
#
# `CollectionGroupAccessForm` only ever *picked* an existing `auth.Group` —
# creating one, or changing who belongs to it, required the Django admin.
# This gives Collection Managers a lightweight, repository-scoped page to do
# both without leaving the app.


class GroupManageView(RepoManagerRequiredMixin, FormView):
    template_name = "documents/group_manage.html"
    form_class = GroupCreateForm

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["groups"] = Group.objects.annotate(
            member_count=Count("user", distinct=True)
        ).order_by("name")
        selected = None
        selected_id = self.request.GET.get("group", "")
        if selected_id.isdigit():
            selected = Group.objects.filter(pk=int(selected_id)).first()
        ctx["selected_group"] = selected
        if selected is not None:
            ctx["members"] = selected.user_set.order_by(
                "first_name", "last_name", "email"
            )
            ctx["member_form"] = GroupMemberAddForm(group=selected)
        return ctx

    def form_valid(self, form):
        group = form.save()
        messages.success(self.request, f'Group "{group.name}" created.')
        return redirect(f"{reverse('repo_documents:group_manage')}?group={group.pk}")


class GroupMemberAddView(RepoManagerRequiredMixin, View):
    def post(self, request, pk):
        group = get_object_or_404(Group, pk=pk)
        form = GroupMemberAddForm(request.POST, group=group)
        redirect_url = f"{reverse('repo_documents:group_manage')}?group={group.pk}"
        if not form.is_valid():
            messages.error(request, "Pick a valid user to add.")
            return redirect(redirect_url)
        user = form.cleaned_data["user"]
        group.user_set.add(user)
        messages.success(
            request, f"Added {user.get_full_name() or user.email} to “{group.name}”."
        )
        return redirect(redirect_url)


class GroupMemberRemoveView(RepoManagerRequiredMixin, View):
    def post(self, request, pk, user_id):
        group = get_object_or_404(Group, pk=pk)
        user = get_object_or_404(get_user_model(), pk=user_id)
        group.user_set.remove(user)
        messages.success(
            request, f"Removed {user.get_full_name() or user.email} from “{group.name}”."
        )
        return redirect(f"{reverse('repo_documents:group_manage')}?group={group.pk}")


# ── Author + taxonomy merge wizards (FRREP-MCL004 + FRREP-MCL012) ──────────


class AuthorMergeView(RepoManagerRequiredMixin, FormView):
    """Pick a primary Author and one or more duplicates to fold into it."""

    template_name = "documents/author_merge.html"
    form_class = AuthorMergeForm

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["authors"] = (
            Author.objects.annotate(
                document_count=Count("documentauthor", distinct=True)
            )
            .select_related("institution")
            .prefetch_related("aliases")
            .order_by("last_name", "first_name")
        )
        ctx["authors_total"] = ctx["authors"].count()
        ctx["cancel_url"] = reverse("repo_documents:collection_list")
        return ctx

    def form_valid(self, form):
        primary = form.cleaned_data["primary"]
        duplicates = list(form.cleaned_data["duplicates"])
        try:
            services.merge_authors(
                primary,
                duplicates,
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        names = ", ".join(d.full_name for d in duplicates)
        messages.success(
            self.request,
            f"Merged {len(duplicates)} author(s) — {names} — into {primary.full_name}.",
        )
        return redirect(reverse("repo_documents:author_merge"))


class CollectionMergeView(RepoManagerRequiredMixin, FormView):
    """Move documents from duplicate Collections into a primary, then delete duplicates."""

    template_name = "documents/collection_merge.html"
    form_class = CollectionMergeForm

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["collections"] = (
            Collection.objects.annotate(
                document_count=Count("documents", distinct=True)
            )
            .order_by("name")
        )
        ctx["collections_total"] = ctx["collections"].count()
        ctx["cancel_url"] = reverse("repo_documents:collection_list")
        return ctx

    def form_valid(self, form):
        primary = form.cleaned_data["primary"]
        duplicates = list(form.cleaned_data["duplicates"])
        try:
            services.merge_collections(
                primary,
                duplicates,
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        names = ", ".join(d.name for d in duplicates)
        messages.success(
            self.request,
            f"Merged {len(duplicates)} collection(s) — {names} — into {primary.name}. "
            "Old slugs now redirect to the primary.",
        )
        return redirect(
            "repo_documents:collection_detail", slug=primary.slug
        )


class CollectionRenameView(RepoManagerRequiredMixin, FormView):
    """Rename a Collection's slug, recording the old slug as a 301 alias."""

    template_name = "documents/collection_rename.html"
    form_class = CollectionRenameForm

    def dispatch(self, request, *args, **kwargs):
        self.collection = get_object_or_404(Collection, slug=kwargs["slug"])
        return super().dispatch(request, *args, **kwargs)

    def get_initial(self):
        return {"new_slug": self.collection.slug}

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["collection"] = self.collection
        ctx["cancel_url"] = reverse(
            "repo_documents:collection_detail", kwargs={"slug": self.collection.slug}
        )
        ctx["existing_aliases"] = (
            CollectionSlugAlias.objects.filter(collection=self.collection)
            .order_by("-created_at")
        )
        return ctx

    def form_valid(self, form):
        try:
            services.rename_collection_slug(
                self.collection,
                form.cleaned_data["new_slug"],
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error("new_slug", str(exc))
            return self.form_invalid(form)
        messages.success(
            self.request,
            f"Slug updated. Old URL still resolves via 301 redirect.",
        )
        return redirect(
            "repo_documents:collection_detail", slug=self.collection.slug
        )


class CollectionDeprecateView(RepoManagerRequiredMixin, FormView):
    """Mark a Collection as deprecated (FRREP-MCL012). The collection remains
    queryable; new submissions show a warning suggesting the replacement."""

    template_name = "documents/collection_deprecate.html"
    form_class = CollectionDeprecateForm

    def dispatch(self, request, *args, **kwargs):
        self.collection = get_object_or_404(Collection, slug=kwargs["slug"])
        return super().dispatch(request, *args, **kwargs)

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["collection"] = self.collection
        return kwargs

    def get_initial(self):
        return {
            "deprecation_message": self.collection.deprecation_message,
            "superseded_by": self.collection.superseded_by_id,
        }

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["collection"] = self.collection
        ctx["cancel_url"] = reverse(
            "repo_documents:collection_detail", kwargs={"slug": self.collection.slug}
        )
        return ctx

    def post(self, request, *args, **kwargs):
        if request.POST.get("undeprecate") == "1":
            services.undeprecate_collection(
                self.collection,
                actor=request.user,
                request=request,
            )
            messages.success(request, "Deprecation cleared.")
            return redirect(
                "repo_documents:collection_detail", slug=self.collection.slug
            )
        return super().post(request, *args, **kwargs)

    def form_valid(self, form):
        try:
            services.deprecate_collection(
                self.collection,
                message=form.cleaned_data["deprecation_message"],
                superseded_by=form.cleaned_data.get("superseded_by"),
                actor=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error("deprecation_message", str(exc))
            return self.form_invalid(form)
        messages.success(self.request, f"'{self.collection.name}' is now marked deprecated.")
        return redirect(
            "repo_documents:collection_detail", slug=self.collection.slug
        )


# ── Access requests + sharing ──────────────────────────────────────────────


class AccessRequestCreateView(LoginRequiredMixin, FormView):
    template_name = "documents/access_request_form.html"
    form_class = AccessRequestForm

    def dispatch(self, request, *args, **kwargs):
        self.document = get_object_or_404(Document, document_uid=kwargs["uid"])
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        return ctx

    def form_valid(self, form):
        try:
            services.request_access(
                self.document,
                self.request.user,
                form.cleaned_data["justification"],
            )
        except ValidationError as exc:
            form.add_error("justification", str(exc))
            return self.form_invalid(form)
        messages.success(self.request, "Access request submitted.")
        return redirect(document_detail_url(self.document))


class AccessRequestQueueView(RepoManagerRequiredMixin, ListView):
    template_name = "documents/access_request_queue.html"
    paginate_by = 50
    context_object_name = "access_requests"

    def get_queryset(self):
        return AccessRequest.objects.filter(status=AccessRequest.Status.PENDING).select_related(
            "document", "requester"
        )


class AccessRequestDecideView(RepoManagerRequiredMixin, View):
    def post(self, request, pk, decision):
        ar = get_object_or_404(AccessRequest, pk=pk)
        approve = decision == "approve"
        try:
            services.decide_access_request(
                ar,
                approve=approve,
                note=request.POST.get("note", ""),
                actor=request.user,
                request=request,
            )
        except ValidationError as exc:
            messages.error(request, str(exc))
        else:
            messages.success(request, f"Access request {decision}d.")
        return redirect("repo_documents:access_request_queue")


class AccessViolationListView(RepoManagerRequiredMixin, ListView):
    """REPO-SP4 (2026-07 interviews) — the Repository Administrator "reviews
    access violations and unauthorized access attempts". Lists the denial
    trail recorded by ``services.log_access_violation``."""

    template_name = "documents/access_violation_list.html"
    paginate_by = 50
    context_object_name = "violations"

    def get_queryset(self):
        qs = AccessViolation.objects.select_related(
            "document", "document__collection", "user"
        )
        action = self.request.GET.get("action")
        if action in AccessViolation.Action.values:
            qs = qs.filter(action=action)
        who = self.request.GET.get("who")
        if who == "anonymous":
            qs = qs.filter(user__isnull=True)
        elif who == "authenticated":
            qs = qs.filter(user__isnull=False)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["action_choices"] = AccessViolation.Action.choices
        ctx["current_action"] = self.request.GET.get("action", "")
        ctx["current_who"] = self.request.GET.get("who", "")
        window_start = timezone.now() - timedelta(days=30)
        ctx["total_30d"] = AccessViolation.objects.filter(timestamp__gte=window_start).count()
        ctx["repeat_offenders"] = (
            AccessViolation.objects.filter(timestamp__gte=window_start, user__isnull=False)
            .values("user__email")
            .annotate(n=Count("pk"))
            .filter(n__gte=3)
            .order_by("-n")[:5]
        )
        return ctx


class ShareDocumentView(LoginRequiredMixin, FormView):
    template_name = "documents/share_modal.html"
    form_class = ShareForm

    def dispatch(self, request, *args, **kwargs):
        self.document = get_object_or_404(Document, document_uid=kwargs["uid"])
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        # FRREP-COL007 — surface who already has access (and let the owner /
        # a manager revoke it) right alongside the "add a collaborator" form;
        # previously there was no page showing a document's current shares.
        can_manage = _can_manage_comment(self.request.user, self.document)
        ctx["can_manage_shares"] = can_manage
        if can_manage:
            ctx["collaborators"] = (
                self.document.shares.filter(revoked_at__isnull=True)
                .select_related("collaborator", "granted_by")
                .order_by("-granted_at")
            )
        return ctx

    def form_valid(self, form):
        services.share_document(
            self.document,
            collaborator=form.cleaned_data["collaborator"],
            permission_level=form.cleaned_data["permission_level"],
            granted_by=self.request.user,
            request=self.request,
        )
        messages.success(
            self.request,
            f"{form.cleaned_data['collaborator'].get_full_name() or form.cleaned_data['collaborator'].email} now has access.",
        )
        return redirect(reverse("repo_documents:document_share", kwargs={"uid": self.document.document_uid}))


class RevokeShareView(LoginRequiredMixin, View):
    def post(self, request, share_id):
        share = get_object_or_404(DocumentShare, pk=share_id)
        if not (
            request.user == share.granted_by
            or request.user.is_superuser
            or _can_manage_comment(request.user, share.document)
        ):
            raise PermissionDenied
        services.revoke_share(share, actor=request.user, request=request)
        messages.success(request, "Access revoked.")
        return redirect(reverse("repo_documents:document_share", kwargs={"uid": share.document.document_uid}))


# ── Comments (HTMX) ─────────────────────────────────────────────────────────


class CommentCreateView(LoginRequiredMixin, View):
    def post(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        # FRREP-COL003 — the comment box is shown to any authenticated user on
        # the detail page (including the document's own author), but the
        # `comment_document` guardian permission is only ever granted via
        # `share_document()`'s permission-level map — never to the submitter.
        # Reuse `_can_manage_comment`'s ownership/manager standard so the
        # author (and managers) can always comment on their own document.
        if not (
            request.user.has_perm("repository_documents.comment_document", document)
            or request.user.is_superuser
            or _can_manage_comment(request.user, document)
        ):
            raise PermissionDenied
        form = CommentForm(request.POST)
        if not form.is_valid():
            return HttpResponse(status=400)
        parent = None
        parent_id = form.cleaned_data.get("parent_id")
        if parent_id:
            parent = DocumentComment.objects.filter(pk=parent_id, document=document).first()
        services.add_comment(
            document=document,
            author=request.user,
            text=form.cleaned_data["text"],
            section_anchor=form.cleaned_data.get("section_anchor", ""),
            parent=parent,
            request=request,
        )
        return redirect(document_detail_url(document))


def _can_manage_comment(user, document) -> bool:
    """Who may resolve/reopen a comment thread (FRREP-COL004) — same standard
    used for document-scoped mutations elsewhere in this file (e.g.
    DocumentEditView.get_object, ReviewTaskCreateView.post)."""
    return bool(
        user.is_superuser
        or user.has_perm("repository_documents.change_document", document)
        or document.submitted_by_id == user.pk
    )


class CommentResolveView(LoginRequiredMixin, View):
    def post(self, request, uid, comment_id):
        document = get_object_or_404(Document, document_uid=uid)
        comment = get_object_or_404(DocumentComment, pk=comment_id, document=document)
        if not _can_manage_comment(request.user, document):
            raise PermissionDenied
        services.resolve_comment(comment, actor=request.user, request=request)
        messages.success(request, "Comment marked resolved.")
        return redirect(document_detail_url(document))


class CommentReopenView(LoginRequiredMixin, View):
    def post(self, request, uid, comment_id):
        document = get_object_or_404(Document, document_uid=uid)
        comment = get_object_or_404(DocumentComment, pk=comment_id, document=document)
        if not _can_manage_comment(request.user, document):
            raise PermissionDenied
        services.reopen_comment(comment, actor=request.user, request=request)
        messages.info(request, "Comment reopened.")
        return redirect(document_detail_url(document))


# ── Versions ────────────────────────────────────────────────────────────────


class VersionListView(ListView):
    template_name = "documents/version_history.html"
    context_object_name = "versions"
    paginate_by = 25

    def get_queryset(self):
        self.document = get_object_or_404(Document, document_uid=self.kwargs["uid"])
        # FRREP-AC002/VL003 — version history exposes historical files of a
        # document; gate it with the same visibility rule as the detail page so
        # Restricted/Internal documents don't leak their earlier revisions.
        if not can_access(self.request.user, self.document):
            return _document_access_denied_response(self.request, self.document)
        return self.document.versions.order_by("-uploaded_at")

    def get(self, request, *args, **kwargs):
        response = self.get_queryset()
        if isinstance(response, HttpResponse):
            return response
        self.object_list = response
        context = self.get_context_data()
        return self.render_to_response(context)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        ctx["can_download"] = can_download(self.request.user, self.document)
        return ctx


class VersionDownloadView(View):
    """FRREP-AC007/VL003 — serve a *specific* historical version through the
    same visibility gate + watermark pipeline as the current file, instead of
    linking the raw media URL (which bypassed both)."""

    def get(self, request, uid, pk):
        document = get_object_or_404(Document, document_uid=uid)
        version = get_object_or_404(document.versions, pk=pk)
        if not can_download(request.user, document):
            return _document_access_denied_response(request, document)
        try:
            from apps.repository.analytics.services import record_download_event

            record_download_event(document, None, request.user, request)
        except Exception:  # pragma: no cover
            pass
        return _serve_document_file(
            request, document, as_attachment=True, file=version.file
        )


class VersionUploadView(LoginRequiredMixin, FormView):
    template_name = "documents/version_upload.html"
    form_class = VersionUploadForm

    def dispatch(self, request, *args, **kwargs):
        self.document = get_object_or_404(Document, document_uid=kwargs["uid"])
        u = request.user
        if not (u == self.document.submitted_by or u.is_superuser or u.role in REPO_MANAGER_ROLES):
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        return ctx

    def form_valid(self, form):
        try:
            services.add_version(
                self.document,
                file=form.cleaned_data["file"],
                changelog=form.cleaned_data.get("changelog", ""),
                version_type=form.cleaned_data["version_type"],
                uploaded_by=self.request.user,
                request=self.request,
            )
        except ValidationError as exc:
            form.add_error("file", str(exc))
            return self.form_invalid(form)
        messages.success(self.request, "New version uploaded.")
        return redirect(document_detail_url(self.document))


# ── AI insight review (REPO-SP9 / FRREP-AR007-AR008) ────────────────────────


class AIInsightDecideView(LoginRequiredMixin, View):
    """Accept or dismiss an AI classification/metadata suggestion.

    Accept applies the suggestion (moves the document to the suggested
    collection for a classification insight; fills currently-blank document
    fields for a metadata insight). Dismiss just records the decision.
    """

    def post(self, request, uid, pk):
        from apps.repository.analytics.models import AIDocumentInsight

        document = get_object_or_404(Document, document_uid=uid)
        insight = get_object_or_404(AIDocumentInsight, pk=pk, document=document)
        if not _can_manage_comment(request.user, document):
            raise PermissionDenied
        decision = request.POST.get("decision")
        if decision not in {"accept", "dismiss"}:
            messages.error(request, "Invalid decision.")
            return redirect(document_detail_url(document))

        try:
            services.decide_ai_insight(insight, decision=decision, actor=request.user, request=request)
        except ValidationError as exc:
            messages.error(request, str(exc))
            return redirect(document_detail_url(document))

        if decision == "accept":
            messages.success(request, "AI suggestion accepted.")
        else:
            messages.info(request, "AI suggestion dismissed.")
        return redirect(document_detail_url(document))


# ── Saved searches ─────────────────────────────────────────────────────────


class SavedSearchListView(LoginRequiredMixin, ListView):
    template_name = "documents/saved_searches.html"
    paginate_by = 50
    context_object_name = "saved_searches"

    def get_queryset(self):
        return SavedSearch.objects.filter(user=self.request.user)


class SavedSearchCreateView(LoginRequiredMixin, FormView):
    template_name = "documents/saved_search_form.html"
    form_class = SavedSearchForm

    def form_valid(self, form):
        SavedSearch.objects.create(
            user=self.request.user,
            name=form.cleaned_data["name"],
            alert_enabled=form.cleaned_data["alert_enabled"],
            query={"q": self.request.GET.get("q", ""), "filters": dict(self.request.GET.items())},
        )
        return redirect("repo_documents:saved_search_list")


class SavedSearchDeleteView(LoginRequiredMixin, DeleteView):
    model = SavedSearch
    template_name = "documents/saved_search_confirm_delete.html"

    def get_queryset(self):
        return SavedSearch.objects.filter(user=self.request.user)

    def get_success_url(self):
        return reverse("repo_documents:saved_search_list")


# ── Document bookmarks (FRREP-VL007 — "saved documents") ────────────────────


class DocumentSaveView(LoginRequiredMixin, View):
    """Bookmark a single document (idempotent — re-saving is a no-op)."""

    def post(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        DocumentBookmark.objects.get_or_create(user=request.user, document=document)
        messages.success(request, "Saved to your documents.")
        return redirect(document_detail_url(document))


class DocumentUnsaveView(LoginRequiredMixin, View):
    """Remove a bookmark from a document."""

    def post(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        DocumentBookmark.objects.filter(user=request.user, document=document).delete()
        messages.info(request, "Removed from your saved documents.")
        return redirect(document_detail_url(document))


class MyBookmarksListView(LoginRequiredMixin, ListView):
    template_name = "documents/my_bookmarks.html"
    paginate_by = 50
    context_object_name = "bookmarks"

    def get_queryset(self):
        return (
            DocumentBookmark.objects.filter(user=self.request.user)
            .select_related("document", "document__collection")
            .order_by("-created_at")
        )


# ── Review tasks (REPO-SP7 FRREP-COL005/006) ──────────────────────────────


class ReviewTaskListView(LoginRequiredMixin, ListView):
    template_name = "documents/review_task_list.html"
    paginate_by = 50
    context_object_name = "review_tasks"

    def get_queryset(self):
        self.document = get_object_or_404(Document, document_uid=self.kwargs["uid"])
        return (
            DocumentReviewTask.objects.filter(document=self.document)
            .select_related("assignee", "created_by")
            .order_by("status", "due_date", "-created_at")
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["document"] = self.document
        ctx["form"] = ReviewTaskForm()
        u = self.request.user
        can_manage = bool(
            u.is_authenticated
            and (
                u.is_superuser
                or u.has_perm("repository_documents.change_document", self.document)
                or self.document.submitted_by_id == u.pk
            )
        )
        ctx["can_manage_tasks"] = can_manage
        today = timezone.now().date()
        for task in ctx.get("review_tasks") or []:
            task.is_overdue = bool(
                task.due_date
                and task.due_date < today
                and task.status != DocumentReviewTask.Status.COMPLETED
            )
            task.is_due_soon = bool(
                task.due_date
                and not task.is_overdue
                and (task.due_date - today).days <= 2
                and task.status != DocumentReviewTask.Status.COMPLETED
            )
            # FRREP-COL005 — status action buttons are only meaningful for the
            # assignee (who does the work) or someone who can manage the
            # document (owner/manager), matching the create-task guard above.
            task.can_update_status = bool(
                u.is_authenticated and (u.pk == task.assignee_id or can_manage)
            )
        return ctx


class ReviewTaskCreateView(LoginRequiredMixin, View):
    """Assign a review task on a document. Reachable from the document detail page."""

    def post(self, request, uid):
        document = get_object_or_404(Document, document_uid=uid)
        u = request.user
        if not (
            u.is_superuser
            or u.has_perm("repository_documents.change_document", document)
            or document.submitted_by_id == u.pk
        ):
            raise PermissionDenied
        form = ReviewTaskForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Could not create review task — please correct the form.")
            return redirect(reverse("repo_documents:review_task_list", kwargs={"uid": uid}))
        try:
            services.add_review_task(
                document,
                assignee=form.cleaned_data["assignee"],
                scope=form.cleaned_data["scope"],
                instructions=form.cleaned_data.get("instructions", ""),
                due_date=form.cleaned_data.get("due_date"),
                created_by=request.user,
                request=request,
            )
        except ValidationError as exc:
            messages.error(request, str(exc))
            return redirect(reverse("repo_documents:review_task_list", kwargs={"uid": uid}))
        messages.success(request, "Review task assigned.")
        return redirect(reverse("repo_documents:review_task_list", kwargs={"uid": uid}))


class ReviewTaskUpdateStatusView(LoginRequiredMixin, View):
    """FRREP-COL005 — close the loop `ReviewTaskCreateView` opened: let the
    assignee (or the document's owner/manager) move a task through
    Pending → In progress → Completed."""

    def post(self, request, uid, pk):
        document = get_object_or_404(Document, document_uid=uid)
        task = get_object_or_404(DocumentReviewTask, pk=pk, document=document)
        u = request.user
        can_manage = bool(
            u.is_superuser
            or u.has_perm("repository_documents.change_document", document)
            or document.submitted_by_id == u.pk
        )
        if not (u.pk == task.assignee_id or can_manage):
            raise PermissionDenied
        status = (request.POST.get("status") or "").strip()
        try:
            services.update_review_task_status(
                task, status=status, actor=u, request=request
            )
        except ValidationError as exc:
            messages.error(request, str(exc))
        else:
            messages.success(request, f"Task marked {task.get_status_display().lower()}.")
        return redirect(reverse("repo_documents:review_task_list", kwargs={"uid": uid}))


# ── Keyword vocabulary admin (REPO-SP2 FRREP-MCL007) ────────────────────────


class KeywordListView(RepoManagerRequiredMixin, ListView):
    template_name = "documents/keyword_list.html"
    paginate_by = 50
    context_object_name = "keywords"

    def get_queryset(self):
        status = self.request.GET.get("status", "pending")
        qs = Keyword.objects.select_related("suggested_by", "reviewed_by")
        if status in {Keyword.Status.PENDING, Keyword.Status.APPROVED, Keyword.Status.REJECTED}:
            qs = qs.filter(status=status)
        return qs.order_by("-created_at")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_filter"] = self.request.GET.get("status", "pending")
        ctx["pending_count"] = Keyword.objects.filter(
            status=Keyword.Status.PENDING
        ).count()
        return ctx


class KeywordDecideView(RepoManagerRequiredMixin, View):
    def post(self, request, pk):
        keyword = get_object_or_404(Keyword, pk=pk)
        decision = request.POST.get("decision")
        if decision not in {"approve", "reject"}:
            messages.error(request, "Invalid decision.")
            return redirect("repo_documents:keyword_list")
        services.review_keyword(
            keyword,
            approve=(decision == "approve"),
            actor=request.user,
            request=request,
        )
        messages.success(request, f'"{keyword.label}" {decision}d.')
        return redirect(
            f"{reverse('repo_documents:keyword_list')}?status="
            + (request.GET.get("status") or "pending")
        )


# ── helpers ────────────────────────────────────────────────────────────────


def document_detail_url(document: Document) -> str:
    return reverse("repo_documents:document_detail", kwargs={"uid": document.document_uid})
