"""Deterministic analytics for the Repository (PRD REPO-SP9)."""

from __future__ import annotations

import io
from collections import defaultdict
from datetime import date, timedelta
from typing import Any

from django.db.models import Count, Max, Min, Q
from django.utils import timezone

from apps.core.audit.middleware import AuditMiddleware
from apps.core.countries import to_country_name


def _collect_request_meta(request) -> dict[str, Any]:
    if request is None:
        return {"ip": None, "ua": "", "country": ""}
    return {
        "ip": AuditMiddleware._client_ip(request),
        "ua": (request.META.get("HTTP_USER_AGENT") or "")[:512],
        # Cloudflare sends an ISO alpha-2 code; canonicalise it to the full
        # country name we now store platform-wide ("" when the header is absent).
        "country": to_country_name(request.META.get("HTTP_CF_IPCOUNTRY") or ""),
    }


# Usage-count de-duplication windows. A single logical "view" of a document
# fans out into several HTTP GETs — the detail page, the in-app reader page, the
# reader's embedded `document_raw` iframe, an OER-link redirect hop — and a
# single download triggers the browser's probe/range second request. Counting
# each sub-request inflated views ~6x and downloads ~2x (Search & Retrieval
# FR#17/#19). We collapse repeat hits from the same viewer within a short window
# to one event. The SRS explicitly permits a de-duplication policy
# ("Duplicate downloads may be counted according to repository analytics policy").
_ACCESS_DEDUP_SECONDS = 30 * 60  # one unique view per viewer per 30 minutes
_DOWNLOAD_DEDUP_SECONDS = 5 * 60  # one download per viewer per 5 minutes


def _viewer_filter(user, ip):
    """Filter kwargs identifying the same viewer (auth user, else client IP)."""
    if user and getattr(user, "is_authenticated", False):
        return {"user": user}
    if ip:
        return {"user__isnull": True, "ip_address": ip}
    return None


def _recent_event_exists(model, document, viewer_filter, window_seconds) -> bool:
    if viewer_filter is None:
        return False
    since = timezone.now() - timedelta(seconds=window_seconds)
    return model.objects.filter(
        document=document, timestamp__gte=since, **viewer_filter
    ).exists()


def record_access_event(document, user, request, *, source: str | None = None) -> None:
    from apps.repository.analytics.models import AccessEvent

    meta = _collect_request_meta(request)
    viewer = _viewer_filter(user, meta["ip"])
    if _recent_event_exists(AccessEvent, document, viewer, _ACCESS_DEDUP_SECONDS):
        return
    AccessEvent.objects.create(
        document=document,
        user=user if user and user.is_authenticated else None,
        ip_address=meta["ip"],
        user_agent=meta["ua"],
        country=meta["country"],
        referrer=(request.META.get("HTTP_REFERER") or "")[:500] if request is not None else "",
        source=source or AccessEvent.Source.DIRECT,
    )


def record_download_event(document, version, user, request) -> None:
    from apps.repository.analytics.models import DownloadEvent

    meta = _collect_request_meta(request)
    viewer = _viewer_filter(user, meta["ip"])
    if _recent_event_exists(DownloadEvent, document, viewer, _DOWNLOAD_DEDUP_SECONDS):
        return
    DownloadEvent.objects.create(
        document=document,
        version=version,
        user=user if user and user.is_authenticated else None,
        ip_address=meta["ip"],
        country=meta["country"],
    )


def record_search_event(
    *,
    query: str,
    filters: dict,
    result_count: int,
    clicked_doc,
    user,
    request,
) -> int:
    """Persist a search event and return its pk.

    FRREP-AR011 — the pk is threaded back to the caller (search views) so
    result links can carry it (``?se=<pk>``) and later be resolved back to a
    click-through via :func:`record_search_click`.
    """
    from apps.repository.analytics.models import SearchEvent

    event = SearchEvent.objects.create(
        query=query,
        filters=filters,
        result_count=result_count,
        clicked_document=clicked_doc,
        user=user if user and user.is_authenticated else None,
    )
    return event.pk


def record_search_click(search_event_id, document) -> bool:
    """Mark a SearchEvent as having led to a document click-through (FRREP-AR011).

    Called when a reader follows a ``?se=<SearchEvent.pk>`` link from search
    results to a document's detail page. Best-effort: an absent, malformed,
    or unknown id is a silent no-op so a stale/forged query param never
    breaks the document page. Idempotent — re-visiting with the same ``se``
    just re-sets the same value.
    """
    from apps.repository.analytics.models import SearchEvent

    try:
        se_id = int(search_event_id)
    except (TypeError, ValueError):
        return False
    updated = SearchEvent.objects.filter(pk=se_id).update(clicked_document=document)
    return bool(updated)


def get_top_queries(
    limit: int = 10,
    *,
    date_from: date | None = None,
    date_to: date | None = None,
) -> list[dict[str, Any]]:
    """Cheap aggregate of top-N user search queries within a date window.

    Returns dicts shaped as ``{"query": str, "total": int}`` so the analytics
    dashboard and the search empty-state can share the same payload.
    """
    from apps.repository.analytics.models import SearchEvent

    today = timezone.now().date()
    date_from = date_from or today - timedelta(days=90)
    date_to = date_to or today

    search_qs = SearchEvent.objects.filter(
        timestamp__date__gte=date_from, timestamp__date__lte=date_to
    )
    return list(
        search_qs.exclude(query="")
        .values("query")
        .annotate(total=Count("id"))
        .order_by("-total")[:limit]
    )


def get_zero_result_queries(*, limit: int = 20) -> list[dict[str, Any]]:
    """Queries that returned nothing — signals content/collection gaps (FRREP-AR011).

    Grouped the same way as :func:`get_top_queries` (``.values("query")
    .annotate(...)``), but all-time rather than windowed: zero-result
    searches are rare enough that a short rolling window would mostly show
    noise, and managers want to see *any* recurring gap. Blank queries
    (filter-only searches with no text) are excluded since they have no
    meaningful "query" to report.
    """
    from apps.repository.analytics.models import SearchEvent

    return list(
        SearchEvent.objects.filter(result_count=0)
        .exclude(query="")
        .values("query")
        .annotate(count=Count("id"))
        .order_by("-count")[:limit]
    )


def get_search_conversion_rate() -> dict[str, Any]:
    """Search → click-through rate, among searches that returned results (FRREP-AR011).

    This reports search → document **click-through** (via
    ``SearchEvent.clicked_document``), not search → **download**: there is no
    shared session/request identifier linking a ``SearchEvent`` to a later
    ``DownloadEvent``, so any correlation between the two would be a fragile
    heuristic (e.g. guessing by time proximity). Click-through to a document's
    detail page is the closest signal this model reliably tracks, so it is
    reported as the conversion proxy — callers/templates should label it as
    "click-through", not "download conversion".
    """
    from apps.repository.analytics.models import SearchEvent

    with_results = SearchEvent.objects.filter(result_count__gt=0)
    total = with_results.count()
    converted = with_results.exclude(clicked_document=None).count()
    rate = round((converted / total) * 100, 1) if total else 0.0
    return {
        "total_searches_with_results": total,
        "converted": converted,
        "conversion_rate": rate,
    }


def get_usage_dashboard(
    *,
    date_from: date | None = None,
    date_to: date | None = None,
    collection=None,
    document_type: str | None = None,
    country: str | None = None,
    institution: str | None = None,
) -> dict[str, Any]:
    from apps.repository.analytics.models import AccessEvent, DownloadEvent
    from apps.repository.documents.models import Document

    today = timezone.now().date()
    date_from = date_from or today - timedelta(days=90)
    date_to = date_to or today

    doc_qs = Document.objects.filter(status=Document.Status.PUBLISHED)
    if collection:
        doc_qs = doc_qs.filter(collection=collection)
    if document_type:
        doc_qs = doc_qs.filter(document_type=document_type)
    if country:
        doc_qs = doc_qs.filter(country=country)
    if institution:
        # FRREP-AR001 — "institution" maps to the issuing publisher, matching the
        # Browse-by-institution convention (documents carry no author-institution
        # FK in the imported corpus).
        doc_qs = doc_qs.filter(publisher=institution)

    download_qs = DownloadEvent.objects.filter(timestamp__date__gte=date_from, timestamp__date__lte=date_to)
    access_qs = AccessEvent.objects.filter(timestamp__date__gte=date_from, timestamp__date__lte=date_to)

    by_type = list(
        doc_qs.values("document_type")
        .annotate(total=Count("id"))
        .order_by("-total")
    )

    top_documents = list(
        download_qs.values("document_id", "document__title", "document__public_document_id")
        .annotate(downloads=Count("id"))
        .order_by("-downloads")[:10]
    )

    top_queries = get_top_queries(limit=10, date_from=date_from, date_to=date_to)

    oa_total = doc_qs.count()
    oa_open = doc_qs.filter(visibility=Document.Visibility.PUBLIC_OPEN).count() + doc_qs.filter(
        visibility=Document.Visibility.INHERIT,
        collection__visibility="public_open",
    ).count()
    oa_rate = round((oa_open / oa_total) * 100, 1) if oa_total else 0.0

    # FRREP-AR001 — "new documents added in the period" (by publication date).
    new_documents = doc_qs.filter(
        published_at__date__gte=date_from, published_at__date__lte=date_to
    ).count()

    return {
        "date_from": date_from,
        "date_to": date_to,
        "total_documents": oa_total,
        "new_documents": new_documents,
        "total_downloads": download_qs.count(),
        "total_access": access_qs.count(),
        "unique_visitors": access_qs.values("ip_address").distinct().count(),
        "by_type": by_type,
        "top_documents": top_documents,
        "top_queries": top_queries,
        "open_access_rate": oa_rate,
    }


def get_corpus_analytics() -> dict[str, Any]:
    """All-time content analytics describing the published corpus itself.

    Independent of usage events — these populate immediately and describe the
    archive (year span, collections, geography, subjects, authors). Computed over
    ``status=PUBLISHED`` documents.
    """
    from apps.repository.documents.models import Author, Document, Keyword

    PUBLISHED = Document.Status.PUBLISHED
    docs = Document.objects.filter(status=PUBLISHED)

    years = docs.aggregate(lo=Min("year"), hi=Max("year"))

    # Continuous year axis: gap-fill missing years with 0 so the x-axis reads as
    # a real timeline (even spacing) rather than equal-width discrete data-years.
    year_map = {
        r["year"]: r["total"]
        for r in docs.filter(year__isnull=False)
        .values("year")
        .annotate(total=Count("id"))
    }
    by_year = []
    year_peak = None
    if year_map:
        lo, hi = min(year_map), max(year_map)
        by_year = [{"year": y, "total": year_map.get(y, 0)} for y in range(lo, hi + 1)]
        pk_year = max(year_map, key=year_map.get)
        year_peak = {"year": pk_year, "total": year_map[pk_year]}
    by_collection = list(
        docs.values("collection__name")
        .annotate(total=Count("id"))
        .order_by("-total")
    )
    by_region = list(
        docs.filter(regions__isnull=False)
        .values("regions__name")
        .annotate(total=Count("id", distinct=True))
        .order_by("-total")
    )
    top_countries = list(
        docs.exclude(country="")
        .values("country")
        .annotate(total=Count("id"))
        .order_by("-total")[:10]
    )
    top_conferences = list(
        docs.exclude(conference_name="")
        .values("conference_name")
        .annotate(total=Count("id"))
        .order_by("-total")[:10]
    )
    top_subjects = list(
        Keyword.objects.filter(
            kind=Keyword.Kind.AGROVOC, status=Keyword.Status.APPROVED
        )
        .annotate(total=Count("documents", filter=Q(documents__status=PUBLISHED)))
        .filter(total__gt=0)
        .order_by("-total")
        .values("label", "slug", "total")[:10]
    )
    top_authors = list(
        Author.objects.annotate(
            total=Count("documents", filter=Q(documents__status=PUBLISHED))
        )
        .filter(total__gt=0)
        .order_by("-total")
        .values("id", "first_name", "last_name", "total")[:10]
    )

    return {
        "total_authors": Author.objects.filter(documents__status=PUBLISHED)
        .distinct()
        .count(),
        "total_collections": docs.values("collection").distinct().count(),
        "countries_covered": docs.exclude(country="").values("country").distinct().count(),
        "year_min": years["lo"],
        "year_max": years["hi"],
        "by_year": by_year,
        "year_peak": year_peak,
        "by_collection": by_collection,
        "by_region": by_region,
        "top_countries": top_countries,
        "top_conferences": top_conferences,
        "top_subjects": top_subjects,
        "top_authors": top_authors,
    }


def get_per_document_stats(document) -> dict[str, Any]:
    from apps.repository.analytics.models import AccessEvent, DownloadEvent, SearchEvent

    downloads = DownloadEvent.objects.filter(document=document)
    accesses = AccessEvent.objects.filter(document=document)
    by_country = list(
        accesses.exclude(country="")
        .values("country")
        .annotate(total=Count("id"))
        .order_by("-total")
    )
    search_clicks = SearchEvent.objects.filter(clicked_document=document).count()
    return {
        "downloads": downloads.count(),
        "views": accesses.count(),
        # FRREP-INT006 — reach delivered via REP lesson OER links, as opposed
        # to direct Repository browsing.
        "views_from_rep": accesses.filter(source=AccessEvent.Source.REP).count(),
        "unique_visitors": accesses.values("ip_address").distinct().count(),
        "by_country": by_country,
        "search_click_throughs": search_clicks,
    }


def get_author_impact(author) -> dict[str, Any]:
    from apps.repository.analytics.models import AccessEvent, DownloadEvent

    docs = author.documents.all()
    return {
        "total_documents": docs.count(),
        "total_downloads": DownloadEvent.objects.filter(document__in=docs).count(),
        "total_views": AccessEvent.objects.filter(document__in=docs).count(),
        "top_documents": list(
            DownloadEvent.objects.filter(document__in=docs)
            .values("document__public_document_id", "document__title")
            .annotate(total=Count("id"))
            .order_by("-total")[:5]
        ),
        # Always-present content (independent of analytics events): the author's
        # published works, so the page is substantive on a fresh repository.
        "documents": list(
            docs.filter(status="published")
            .select_related("collection")
            .order_by("-year", "title")[:50]
        ),
    }


COMPLIANCE_DIMENSIONS = (
    ("collection", "Collection"),
    ("document_type", "Document type"),
    ("institution", "Institution"),
)


def _oa_filter(Document, Collection) -> Q:
    """Shared "is this document Open Access" predicate (FRREP-AR004)."""
    return Q(visibility=Document.Visibility.PUBLIC_OPEN) | Q(
        visibility=Document.Visibility.INHERIT,
        collection__visibility=Collection.Visibility.PUBLIC_OPEN,
    )


def get_open_access_compliance(*, by: str = "collection") -> list[dict]:
    """Open Access compliance broken down by collection, document type, or
    institution (FRREP-AR004).

    ``institution`` has no direct FK on ``Document`` — institutional
    affiliation only exists via ``Document.authors -> Author.institution``,
    so a co-authored document with contributors from N institutions is
    counted under each of those N institutions (``.distinct()`` still
    guards against the M2M join inflating the *document* count itself; the
    "each institution gets credit for its co-authored share" duplication is
    intentional, mirroring how ``get_corpus_analytics`` already double-counts
    multi-region documents under ``by_region``).
    """
    from apps.repository.documents.models import Collection, Document

    oa_q = _oa_filter(Document, Collection)
    data = []
    if by == "document_type":
        type_label = dict(Document.DocumentType.choices)
        for dt_value in dict(Document.DocumentType.choices):
            docs = Document.objects.filter(document_type=dt_value, status=Document.Status.PUBLISHED)
            total = docs.count()
            if not total:
                continue
            open_count = docs.filter(oa_q).count()
            rate = round((open_count / total) * 100, 1) if total else 0.0
            data.append(
                {
                    "label": type_label.get(dt_value, dt_value),
                    "total": total,
                    "open_count": open_count,
                    "rate": rate,
                }
            )
    elif by == "institution":
        from apps.core.authentication.models import Institution

        for inst in Institution.objects.all():
            docs = Document.objects.filter(
                authors__institution=inst, status=Document.Status.PUBLISHED
            ).distinct()
            total = docs.count()
            if not total:
                continue
            open_count = docs.filter(oa_q).count()
            rate = round((open_count / total) * 100, 1) if total else 0.0
            data.append(
                {"label": inst.name, "total": total, "open_count": open_count, "rate": rate}
            )
    else:
        for col in Collection.objects.all():
            docs = Document.objects.filter(collection=col, status=Document.Status.PUBLISHED)
            total = docs.count()
            open_count = docs.filter(oa_q).count()
            rate = round((open_count / total) * 100, 1) if total else 0.0
            data.append(
                {"label": col.name, "total": total, "open_count": open_count, "rate": rate}
            )
    return sorted(data, key=lambda r: r["rate"], reverse=True)


def _label_submitters(rows) -> list[dict]:
    """Attach a human display name (falling back to email) to each submitter row."""
    labelled = []
    for row in rows:
        first = (row.get("submitted_by__first_name") or "").strip()
        last = (row.get("submitted_by__last_name") or "").strip()
        full_name = " ".join(p for p in (first, last) if p)
        labelled.append({**row, "name": full_name})
    return labelled


def get_collection_analytics(collection, *, days: int = 90) -> dict[str, Any]:
    from apps.repository.analytics.models import AccessEvent, DownloadEvent
    from apps.repository.documents.models import Collection, Document, QARecord

    docs = Document.objects.filter(collection=collection)
    published = docs.filter(status=Document.Status.PUBLISHED)
    qa = QARecord.objects.filter(document__collection=collection)
    approved = qa.filter(decision=QARecord.Decision.APPROVE).count()
    decided_total = qa.exclude(decision=QARecord.Decision.PENDING).count()
    deltas = [
        (p - s).days
        for s, p in published.exclude(submitted_at=None, published_at=None).values_list(
            "submitted_at", "published_at"
        )
        if s and p
    ]
    mean_days = round(sum(deltas) / len(deltas), 1) if deltas else None

    open_access_count = published.filter(
        Q(visibility=Document.Visibility.PUBLIC_OPEN)
        | Q(
            visibility=Document.Visibility.INHERIT,
            collection__visibility=Collection.Visibility.PUBLIC_OPEN,
        )
    ).count()
    open_access_rate = (
        round((open_access_count / published.count()) * 100, 1)
        if published.exists()
        else None
    )

    by_type = list(
        published.values("document_type")
        .annotate(total=Count("id"))
        .order_by("-total")
    )
    by_status = list(
        docs.values("status").annotate(total=Count("id")).order_by("-total")
    )

    recent_uploads = list(
        published.order_by("-published_at").values(
            "document_uid",
            "public_document_id",
            "title",
            "document_type",
            "published_at",
        )[:5]
    )

    download_qs = DownloadEvent.objects.filter(document__in=docs)
    top_downloaded = list(
        download_qs.values(
            "document__document_uid",
            "document__public_document_id",
            "document__title",
        )
        .annotate(downloads=Count("id"))
        .order_by("-downloads")[:5]
    )

    # FRREP-AR005 — "most accessed" should reflect reading interest, not just
    # downloads: a document can be read/cited via its detail page (AccessEvent)
    # without ever being downloaded (e.g. restricted-access items, or readers
    # using the inline PDF viewer). Combine both signals into one relevance
    # score, weighting a download 2x a view since completing a download is a
    # stronger engagement signal than a page visit — same 2:1 weighting
    # already used implicitly elsewhere isn't codified, but this is a
    # reasonable, documented default rather than an arbitrary unweighted sum.
    access_qs = AccessEvent.objects.filter(document__in=docs)
    view_counts = dict(
        access_qs.values_list("document_id").annotate(total=Count("id")).order_by()
    )
    download_counts = dict(
        download_qs.values_list("document_id").annotate(total=Count("id")).order_by()
    )
    doc_ids_with_activity = set(view_counts) | set(download_counts)
    most_accessed_rows = []
    for doc_id in doc_ids_with_activity:
        views = view_counts.get(doc_id, 0)
        downloads = download_counts.get(doc_id, 0)
        most_accessed_rows.append(
            {
                "document_id": doc_id,
                "views": views,
                "downloads": downloads,
                "score": downloads * 2 + views,
            }
        )
    most_accessed_rows.sort(key=lambda r: r["score"], reverse=True)
    most_accessed_rows = most_accessed_rows[:5]
    docs_by_id = {
        d.pk: d
        for d in Document.objects.filter(
            pk__in=[r["document_id"] for r in most_accessed_rows]
        ).only("document_uid", "public_document_id", "title")
    }
    most_accessed = [
        {
            "document__document_uid": docs_by_id[r["document_id"]].document_uid,
            "document__public_document_id": docs_by_id[r["document_id"]].public_document_id,
            "document__title": docs_by_id[r["document_id"]].title,
            "views": r["views"],
            "downloads": r["downloads"],
            "score": r["score"],
        }
        for r in most_accessed_rows
        if r["document_id"] in docs_by_id
    ]

    # Last `days` window — uploads vs downloads for a sparkline. Bucketed weekly.
    today = timezone.now().date()
    window_start = today - timedelta(days=days)
    weeks: list[dict] = []
    for i in range(days // 7):
        bucket_start = window_start + timedelta(days=i * 7)
        bucket_end = bucket_start + timedelta(days=7)
        weeks.append(
            {
                "label": bucket_start.strftime("%-d %b"),
                "uploads": published.filter(
                    published_at__date__gte=bucket_start,
                    published_at__date__lt=bucket_end,
                ).count(),
                "downloads": download_qs.filter(
                    timestamp__date__gte=bucket_start,
                    timestamp__date__lt=bucket_end,
                ).count(),
            }
        )

    # FRREP-AR005 — "growth over time" as a cumulative history, not just the
    # last-90-day weekly delta above: a running total of published documents
    # from the collection's first publication through today. Buckets monthly
    # when the span is short enough to stay readable/cheap; falls back to
    # yearly buckets for long-lived archival collections (imported corpora
    # can span decades) so the series doesn't balloon into hundreds of points.
    growth_series: list[dict] = []
    first_published = (
        published.exclude(published_at=None)
        .order_by("published_at")
        .values_list("published_at", flat=True)
        .first()
    )
    if first_published:
        start = first_published.date().replace(day=1)
        span_months = (today.year - start.year) * 12 + (today.month - start.month) + 1
        yearly = span_months > 36
        if yearly:
            cursor = start.replace(month=1)
            label_fmt = "%Y"
        else:
            cursor = start
            label_fmt = "%b %Y"
        end_bucket = today.replace(day=1) if not yearly else today.replace(month=1, day=1)
        while cursor <= end_bucket:
            if yearly:
                next_bucket = cursor.replace(year=cursor.year + 1)
            else:
                next_bucket = (cursor.replace(day=28) + timedelta(days=4)).replace(day=1)
            running_total = published.filter(published_at__date__lt=next_bucket).count()
            growth_series.append({"label": cursor.strftime(label_fmt), "total": running_total})
            cursor = next_bucket

    return {
        "total": docs.count(),
        "published": published.count(),
        "withdrawn": docs.filter(status=Document.Status.WITHDRAWN).count(),
        "qa_approval_rate": round((approved / decided_total) * 100, 1) if decided_total else None,
        "mean_submit_to_publish_days": mean_days,
        "open_access_count": open_access_count,
        "open_access_rate": open_access_rate,
        "by_type": by_type,
        "by_status": by_status,
        "recent_uploads": recent_uploads,
        "top_downloaded": top_downloaded,
        "most_accessed": most_accessed,
        "growth_series": growth_series,
        "top_submitters": _label_submitters(
            docs.exclude(submitted_by=None)
            .values(
                "submitted_by__email",
                "submitted_by__first_name",
                "submitted_by__last_name",
            )
            .annotate(total=Count("id"))
            .order_by("-total")[:5]
        ),
        "download_total": download_qs.count(),
        "weekly_activity": weeks,
        "window_days": days,
        "window_start": window_start,
        "window_end": today,
    }


def compute_weekly_rollup() -> int:
    from apps.repository.analytics.models import AccessEvent, DownloadEvent, UsageRollup
    from apps.repository.documents.models import Document

    today = timezone.now().date()
    period_start = today - timedelta(days=7)
    metrics: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: {"downloads": 0, "views": 0})

    for row in DownloadEvent.objects.filter(
        timestamp__date__gte=period_start, timestamp__date__lte=today
    ).values("document__collection__slug", "document__document_type"):
        metrics[("collection", row["document__collection__slug"])]["downloads"] += 1
        metrics[("document_type", row["document__document_type"])]["downloads"] += 1

    for row in AccessEvent.objects.filter(
        timestamp__date__gte=period_start, timestamp__date__lte=today
    ).values("document__collection__slug", "document__document_type", "country"):
        metrics[("collection", row["document__collection__slug"])]["views"] += 1
        metrics[("document_type", row["document__document_type"])]["views"] += 1
        if row["country"]:
            metrics[("country", row["country"])]["views"] += 1

    created = 0
    for (dimension, key), mtr in metrics.items():
        UsageRollup.objects.update_or_create(
            period_start=period_start,
            period_end=today,
            dimension=dimension,
            key=key or "",
            defaults={"metrics": mtr},
        )
        created += 1

    UsageRollup.objects.update_or_create(
        period_start=period_start,
        period_end=today,
        dimension=UsageRollup.Dimension.OVERALL,
        key="",
        defaults={
            "metrics": {
                "downloads": DownloadEvent.objects.filter(timestamp__date__gte=period_start).count(),
                "views": AccessEvent.objects.filter(timestamp__date__gte=period_start).count(),
                "published_total": Document.objects.filter(status=Document.Status.PUBLISHED).count(),
            }
        },
    )
    return created + 1


def get_by_type_with_bars(documents_qs) -> list[dict]:
    """Materialise document_type counts as horizontal-bar-friendly rows.

    Returns ``[{type, label, count, percentage}]`` ordered by count desc, with
    ``percentage`` normalised against the largest bucket (so the leader fills
    the full width).
    """
    from apps.repository.documents.models import Document

    type_label = dict(Document.DocumentType.choices)
    rows = list(
        documents_qs.values("document_type")
        .annotate(count=Count("id"))
        .order_by("-count")
    )
    if not rows:
        return []
    leader = max(r["count"] for r in rows)
    return [
        {
            "type": r["document_type"],
            "label": type_label.get(r["document_type"], r["document_type"]),
            "count": r["count"],
            "percentage": round((r["count"] / leader) * 100, 1) if leader else 0,
        }
        for r in rows
    ]


def export_report(kind: str, fmt: str, **params: Any) -> bytes:
    """Render an analytics report to bytes.

    ``params`` carries kind-specific extras threaded through from the
    request's query string (FRREP-AR006): ``by`` for ``compliance`` (which
    dimension — collection/document_type/institution) and ``slug`` for
    ``collection`` (which collection to report on).
    """
    fmt = fmt.lower()
    if fmt in {"xlsx", "excel"}:
        return _export_xlsx(kind, **params)
    if fmt == "csv":
        return _export_csv(kind, **params)
    if fmt == "pdf":
        return _export_pdf(kind, **params)
    raise ValueError(f"Unsupported format: {fmt}")


def _author_impact_rows() -> list[tuple]:
    """Shared per-author (name, documents, downloads, views) rows (FRREP-AR006)."""
    from apps.repository.documents.models import Author

    top_authors = get_corpus_analytics()["top_authors"]
    rows = []
    for row in top_authors:
        author = Author.objects.filter(pk=row["id"]).first()
        if author is None:
            continue
        impact = get_author_impact(author)
        rows.append(
            (
                f"{row['first_name']} {row['last_name']}".strip(),
                impact["total_documents"],
                impact["total_downloads"],
                impact["total_views"],
            )
        )
    return rows


def _resolve_export_collection(slug: str | None):
    from apps.repository.documents.models import Collection

    if not slug:
        return None
    return Collection.objects.filter(slug=slug).first()


def _export_xlsx(kind: str, *, by: str = "collection", slug: str | None = None, **_ignored) -> bytes:
    from openpyxl import Workbook

    wb = Workbook()
    ws = wb.active
    ws.title = kind[:31]
    if kind == "compliance":
        dim_label = dict(COMPLIANCE_DIMENSIONS).get(by, "Collection")
        ws.append([dim_label, "Total", "Open access", "Rate %"])
        for row in get_open_access_compliance(by=by):
            ws.append([row["label"], row["total"], row["open_count"], row["rate"]])
    elif kind == "usage":
        data = get_usage_dashboard()
        ws.append(["Metric", "Value"])
        for k in ("total_documents", "total_downloads", "total_access", "open_access_rate"):
            ws.append([k, data[k]])
    elif kind == "author_impact":
        ws.append(["Author", "Documents", "Downloads", "Views"])
        for row in _author_impact_rows():
            ws.append(list(row))
    elif kind == "collection":
        collection = _resolve_export_collection(slug)
        if collection is None:
            ws.append(["error", "Unknown or missing collection slug"])
        else:
            data = get_collection_analytics(collection)
            ws.append(["Metric", "Value"])
            ws.append(["Collection", collection.name])
            for k in (
                "total",
                "published",
                "withdrawn",
                "qa_approval_rate",
                "mean_submit_to_publish_days",
                "open_access_rate",
                "download_total",
            ):
                ws.append([k, data[k]])
            ws.append([])
            ws.append(["Most accessed document", "Views", "Downloads"])
            for row in data["most_accessed"]:
                ws.append([row["document__title"], row["views"], row["downloads"]])
    else:
        ws.append(["kind", kind])
    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()


def _export_csv(kind: str, *, by: str = "collection", slug: str | None = None, **_ignored) -> bytes:
    import csv

    buf = io.StringIO()
    writer = csv.writer(buf)
    if kind == "compliance":
        dim_label = dict(COMPLIANCE_DIMENSIONS).get(by, "Collection")
        writer.writerow([dim_label, "Total", "Open access", "Rate %"])
        for row in get_open_access_compliance(by=by):
            writer.writerow([row["label"], row["total"], row["open_count"], row["rate"]])
    elif kind == "author_impact":
        writer.writerow(["Author", "Documents", "Downloads", "Views"])
        for row in _author_impact_rows():
            writer.writerow(list(row))
    elif kind == "collection":
        collection = _resolve_export_collection(slug)
        if collection is None:
            writer.writerow(["error", "Unknown or missing collection slug"])
        else:
            data = get_collection_analytics(collection)
            writer.writerow(["Metric", "Value"])
            writer.writerow(["Collection", collection.name])
            for k in (
                "total",
                "published",
                "withdrawn",
                "qa_approval_rate",
                "mean_submit_to_publish_days",
                "open_access_rate",
                "download_total",
            ):
                writer.writerow([k, data[k]])
    else:
        writer.writerow(["kind", kind])
    return buf.getvalue().encode("utf-8")


def _export_pdf(kind: str, *, by: str = "collection", slug: str | None = None, **_ignored) -> bytes:
    """FRREP-AR006 — PDF export for the manager-facing analytics reports.

    Reuses ``apps.core.utils.pdf.render_pdf`` (the same WeasyPrint helper that
    already renders RIMS award letters and MEL report artifacts) so the
    Repository doesn't introduce a second PDF-rendering dependency. Each
    ``kind`` reuses its existing data-fetching function rather than
    re-querying, and is rendered through the shared
    ``analytics/_export_report_pdf.html`` layout.
    """
    from apps.core.utils.pdf import render_pdf as _render_pdf_template

    context: dict[str, Any] = {"generated_at": timezone.now(), "tables": []}

    if kind == "usage":
        data = get_usage_dashboard()
        context["title"] = "Repository Usage Report"
        context["scope"] = f"{data['date_from']:%d %b %Y} – {data['date_to']:%d %b %Y}"
        context["summary_rows"] = [
            ("Published documents", data["total_documents"]),
            ("Total downloads", data["total_downloads"]),
            ("Total access events", data["total_access"]),
            ("Unique visitors", data["unique_visitors"]),
            ("Open Access rate", f"{data['open_access_rate']}%"),
        ]
        context["tables"] = [
            {
                "heading": "Most downloaded documents",
                "columns": ("Document", "Downloads"),
                "rows": [
                    (row["document__title"], row["downloads"])
                    for row in data["top_documents"]
                ],
            },
            {
                "heading": "Top search queries",
                "columns": ("Query", "Total"),
                "rows": [(row["query"], row["total"]) for row in data["top_queries"]],
            },
        ]
    elif kind == "compliance":
        dim_label = dict(COMPLIANCE_DIMENSIONS).get(by, "Collection")
        rows_data = get_open_access_compliance(by=by)
        total_docs = sum(r["total"] for r in rows_data)
        total_open = sum(r["open_count"] for r in rows_data)
        overall_rate = round((total_open / total_docs) * 100, 1) if total_docs else 0.0
        context["title"] = "Open Access Compliance Report"
        context["scope"] = f"By {dim_label.lower()}"
        context["summary_rows"] = [
            (dim_label + "s", len(rows_data)),
            ("Published documents", total_docs),
            ("Open Access documents", total_open),
            ("Overall OA rate", f"{overall_rate}%"),
        ]
        context["tables"] = [
            {
                "heading": f"Compliance by {dim_label.lower()}",
                "columns": (dim_label, "Total", "Open access", "Rate"),
                "rows": [
                    (r["label"], r["total"], r["open_count"], f"{r['rate']}%")
                    for r in rows_data
                ],
            }
        ]
    elif kind == "author_impact":
        impact_rows = _author_impact_rows()
        context["title"] = "Author Impact Report"
        context["scope"] = "Top published authors (all time)"
        context["summary_rows"] = [("Authors covered", len(impact_rows))]
        context["tables"] = [
            {
                "heading": "Author impact",
                "columns": ("Author", "Documents", "Downloads", "Views"),
                "rows": impact_rows,
            }
        ]
    elif kind == "collection":
        collection = _resolve_export_collection(slug)
        context["title"] = "Collection Analytics Report"
        if collection is None:
            context["scope"] = "Unknown collection"
            context["summary_rows"] = [("Error", "Unknown or missing collection slug")]
        else:
            data = get_collection_analytics(collection)
            context["scope"] = collection.name
            context["summary_rows"] = [
                ("Published", data["published"]),
                ("Total", data["total"]),
                ("Withdrawn", data["withdrawn"]),
                (
                    "Open Access rate",
                    f"{data['open_access_rate']}%" if data["open_access_rate"] is not None else "—",
                ),
                (
                    "QA approval rate",
                    f"{data['qa_approval_rate']}%" if data["qa_approval_rate"] is not None else "—",
                ),
                ("Downloads (all time)", data["download_total"]),
            ]
            context["tables"] = [
                {
                    "heading": "Most accessed documents",
                    "columns": ("Document", "Views", "Downloads"),
                    "rows": [
                        (row["document__title"], row["views"], row["downloads"])
                        for row in data["most_accessed"]
                    ],
                },
                {
                    "heading": "Top submitters",
                    "columns": ("Submitter", "Documents"),
                    "rows": [
                        (row.get("name") or row["submitted_by__email"], row["total"])
                        for row in data["top_submitters"]
                    ],
                },
            ]
    else:
        context["title"] = f"Repository Report — {kind}"
        context["scope"] = ""
        context["summary_rows"] = [("Kind", kind)]

    return _render_pdf_template("analytics/_export_report_pdf.html", context)
