import os
from collections import Counter

from django.conf import settings
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import FileResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone

from accounts.models import LoginEvent
from backoffice.views import _upload_context
from config.context_processors import staff_ui

from .models import BoardMember, Category, Document, DownloadLog, LoginPhoto

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

# Applied to every gated response. noindex keeps documents out of search
# engines; no-store keeps them out of shared caches; attachment is set by
# FileResponse below.
_PRIVATE_HEADERS = {
    "X-Robots-Tag": "noindex, nofollow, noarchive",
    "Cache-Control": "private, no-store",
    "X-Content-Type-Options": "nosniff",
}


def _client_ip(request):
    forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
    if forwarded:
        return forwarded.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR")


def _rate_limited(user):
    limit = getattr(settings, "DOWNLOAD_RATE_LIMIT_PER_MIN", 60)
    window_start = timezone.now() - timezone.timedelta(seconds=60)
    recent = DownloadLog.objects.filter(user=user, downloaded_at__gte=window_start).count()
    return recent >= limit


@login_required
def document_download(request, pk):
    """Stream a gated document to an authenticated, authorised user.

    Default-deny: ``@login_required`` gates every request; an unauthenticated
    caller is redirected to login and never reaches the file. Drafts are
    staff-only. The file lives outside the web root and has no public URL, so
    this view is the only path to it. Every download is logged (who / what /
    when / IP) — the activity trail, and the source for a simple per-user rate
    limit.
    """
    document = get_object_or_404(Document, pk=pk)
    if document.removed_at and not request.user.is_staff:
        raise Http404("Document not found.")
    if not document.published and not request.user.is_staff:
        raise Http404("Document not found.")

    file_field = document.file
    if not file_field:
        raise Http404("File not found.")

    if _rate_limited(request.user):
        return HttpResponse("Too many downloads — please slow down.", status=429)

    DownloadLog.objects.create(
        user=request.user, document=document, ip=_client_ip(request)
    )

    extension = os.path.splitext(file_field.name)[1]
    download_name = f"{document.title}{extension}".strip() or os.path.basename(file_field.name)

    # Preview renders in the browser instead of downloading. Restricted to PDFs
    # on purpose: serving arbitrary types inline would let an uploaded HTML or
    # SVG file execute in the site's origin.
    inline = request.GET.get("preview") == "1" and extension.lower() == ".pdf"

    response = FileResponse(
        file_field.open("rb"), as_attachment=not inline, filename=download_name
    )
    for header, value in _PRIVATE_HEADERS.items():
        response[header] = value
    return response


@login_required
def library(request):
    """The document browser.

    Grouped by category first, because "what kind of thing is this" is how
    people look for a document. The second filter changes with the grouping:
    a time range when grouped by category, categories when grouped by date.
    """
    group_by = request.GET.get("group", "category")
    if group_by not in {"category", "date", "all"}:
        group_by = "category"

    # Time range only applies to the category view; the date view is already
    # ordered by time and filtering it there would be confusing. Defaults to
    # everything: on the quieter properties a past-year default showed two
    # documents out of a hundred and read as an empty library.
    when = request.GET.get("when", "all")
    if when not in {"recent", "current", "last", "all"}:
        when = "all"

    # With dates hidden, the date-derived views have to go too — otherwise a
    # typed URL still files the 2023 AGM minutes under 2026, which is the very
    # thing hiding the dates was meant to stop.
    if not settings.SHOW_DOCUMENT_DATES:
        if group_by == "date":
            group_by = "category"
        when = "all"

    active_category = request.GET.get("category", "").strip()

    visible = (
        Document.objects.filter(published=True, removed_at=None)
        .exclude(collection__removed_at__isnull=False)
        .select_related("category", "collection")
        .prefetch_related("tags")
    )
    documents = visible
    if active_category and group_by != "category":
        documents = documents.filter(category__slug=active_category)

    collections, loose = {}, []
    for document in documents:
        if document.collection_id:
            entry = collections.setdefault(
                document.collection_id,
                {"collection": document.collection, "documents": []},
            )
            entry["documents"].append(document)
        else:
            loose.append(document)

    items = []
    for entry in collections.values():
        collection, members = entry["collection"], entry["documents"]
        if group_by == "category":
            # One batch legitimately spans categories — an AGM produces minutes,
            # financials and a proxy form. In the category view the group is
            # therefore shown under each of them, holding only the documents
            # that belong there, rather than being forced into one.
            by_category = {}
            for document in members:
                by_category.setdefault(document.category_id, []).append(document)
            for category_id, subset in by_category.items():
                items.append({
                    "kind": "collection",
                    "collection": collection,
                    "documents": subset,
                    "date": collection.occurred_on,
                    "count": len(subset),
                    "category": subset[0].category,
                    "partial": len(subset) < len(members),
                })
        else:
            items.append({
                "kind": "collection",
                "collection": collection,
                "documents": members,
                "date": collection.occurred_on,
                "count": len(members),
                "category": members[0].category if members else None,
                "partial": False,
            })
    items += [
        {"kind": "document", "document": document, "date": document.published_date,
         "category": document.category, "partial": False, "count": 1}
        for document in loose
    ]

    today = timezone.now().date()
    this_year, last_year = today.year, today.year - 1

    # Counts for the rail, taken from what is actually visible. Counting through
    # the category relation instead would include removed and unpublished
    # documents, so a category emptied by the trash still showed a tally.
    per_category, per_year, within_a_year = Counter(), Counter(), 0
    year_cutoff = today - timezone.timedelta(days=365)
    for document in visible:
        per_category[document.category_id] += 1
        if document.published_date:
            per_year[document.published_date.year] += 1
            if document.published_date >= year_cutoff:
                within_a_year += 1
    rail_categories = [
        {"category": category, "count": per_category.get(category.pk, 0)}
        for category in Category.objects.all()
        if per_category.get(category.pk, 0)
    ]
    if group_by == "category" and when != "all":
        if when == "recent":
            cutoff = today - timezone.timedelta(days=365)
            items = [i for i in items if i["date"] and i["date"] >= cutoff]
        elif when == "current":
            items = [i for i in items if i["date"] and i["date"].year == this_year]
        elif when == "last":
            items = [i for i in items if i["date"] and i["date"].year == last_year]

    items.sort(key=lambda i: (i["date"] is None, -(i["date"].toordinal() if i["date"] else 0)))
    # Counts are of DOCUMENTS, never of rows. A collection is one row holding
    # several documents, so counting rows made the rail and the section heading
    # disagree about the same category — 31 in one place, 24 in the other.
    shown_total = sum(item["count"] for item in items)

    sections = []
    if group_by == "category":
        # A short view stays whole; a long one collapses to a couple per
        # category so the shape of the library is visible at a glance.
        preview = 2 if shown_total > 8 else None
        buckets = {}
        for item in items:
            key = item["category"].pk if item["category"] else 0
            buckets.setdefault(key, {"category": item["category"], "items": []})
            buckets[key]["items"].append(item)
        ordered = sorted(
            buckets.values(),
            key=lambda b: (b["category"].order, b["category"].name) if b["category"] else (999, "zz"),
        )
        for bucket in ordered:
            entries = bucket["items"]
            sections.append({
                "label": bucket["category"].name if bucket["category"] else "Uncategorised",
                "colour": bucket["category"].colour if bucket["category"] else "#5f6d71",
                "items": entries[:preview] if preview else entries,
                "more": entries[preview:] if preview else [],
                "total": sum(entry["count"] for entry in entries),
            })
    elif group_by == "all":
        sections.append({"label": "Everything", "items": items, "more": [],
                         "total": shown_total, "colour": None})
    else:
        for item in items:
            label = item["date"].year if item["date"] else "Undated"
            if not sections or sections[-1]["label"] != label:
                sections.append({"label": label, "items": [], "more": [],
                                 "total": 0, "colour": None})
            sections[-1]["items"].append(item)
            sections[-1]["total"] += item["count"]

    # What the pane is currently showing, said once at the top rather than
    # leaving the reader to infer it from which rail row is lit.
    if active_category and group_by != "category":
        chosen = next((e for e in rail_categories
                       if e["category"].slug == active_category), None)
        pane_title = chosen["category"].name if chosen else "Documents"
        pane_sub = "%d document%s" % (shown_total, "" if shown_total == 1 else "s")
    elif when == "recent":
        pane_title, pane_sub = "The past year", "%d documents filed since %s" % (
            shown_total, (today - timezone.timedelta(days=365)).strftime("%B %Y"))
    elif when in {"current", "last"}:
        year = this_year if when == "current" else last_year
        pane_title = str(year)
        pane_sub = "%d document%s filed in %s" % (
            shown_total, "" if shown_total == 1 else "s", year)
    else:
        pane_title = "Documents"
        pane_sub = "%d document%s in %d collection%s" % (
            documents.count(), "" if documents.count() == 1 else "s",
            len(collections), "" if len(collections) == 1 else "s")

    return render(
        request,
        "documents/library.html",
        {
            "pane_title": pane_title,
            "pane_sub": pane_sub,
            "sections": sections,
            "group_by": group_by,
            "when": when,
            "this_year": this_year,
            "last_year": last_year,
            "shown_total": shown_total,
            # In the category view the pill on every row just repeats its section.
            # The pill repeats information everywhere except the flat list:
            # in the category view it repeats the section heading, and in a
            # rail-picked category every row is that category by definition.
            "hide_category": group_by != "all",
            "total": documents.count(),
            # The rail always says how big the whole library is. `total` is the
            # count AFTER the category filter, so using it there made "All
            # documents" report the size of whichever collection was open.
            "library_total": visible.count(),
            "collection_count": len(collections),
            "categories": Category.objects.all(),
            "rail_categories": rail_categories,
            "count_recent": within_a_year,
            "count_this_year": per_year.get(this_year, 0),
            "count_last_year": per_year.get(last_year, 0),
            "active_category": active_category,
            "section": "library",
            **(_upload_context() if staff_ui(request) else {"section": "library"}),
            "open_uploader": request.GET.get("upload") == "1",
        },
    )


@login_required
def document_detail(request, pk):
    document = get_object_or_404(Document, pk=pk)
    if document.removed_at and not request.user.is_staff:
        raise Http404("Document not found.")
    if not document.published and not request.user.is_staff:
        raise Http404("Document not found.")
    return render(request, "documents/detail.html",
                  {"document": document, "section": "library"})


@login_required
def board(request):
    return render(
        request,
        "documents/board.html",
        {
            "members": BoardMember.objects.filter(active=True, removed_at=None),
            "removed": (BoardMember.objects.exclude(removed_at=None)
                        if staff_ui(request) else []),
            "section": "board",
        },
    )


def scene_urls():
    """The sign-in backdrop, as URLs. Empty is fine — the page just shows the
    brand colour."""
    from django.urls import reverse
    return [reverse("documents:login_photo", args=[pk])
            for pk in LoginPhoto.objects.values_list("pk", flat=True)]


def opening_url():
    """The one photograph the page opens WITH.

    Chosen here rather than in the browser so the template can preload it in
    the document head: the entrance has a one-second budget and cannot spend
    it waiting for JavaScript to decide which file to ask for. Falls back to
    the whole set when nobody has marked openers, so the scene works
    uncurated.
    """
    import random

    from django.urls import reverse
    pks = list(LoginPhoto.openers().values_list("pk", flat=True))
    return reverse("documents:login_photo", args=[random.choice(pks)]) if pks else ""


def login_photo(request, pk):
    """Serve a sign-in backdrop. Deliberately public — the page it decorates
    is. Only LoginPhoto rows are reachable, by primary key, so this cannot be
    walked into the rest of the private store."""
    photo = get_object_or_404(LoginPhoto, pk=pk)
    response = FileResponse(photo.image.open("rb"), content_type="image/jpeg")
    response["Cache-Control"] = "public, max-age=604800"
    response["X-Content-Type-Options"] = "nosniff"
    return response


@login_required
def board_photo(request, pk):
    """Serve a board member's photo through the gate (private storage)."""
    member = get_object_or_404(BoardMember, pk=pk, active=True)
    if not member.photo:
        raise Http404("No photo.")
    response = FileResponse(member.photo.open("rb"))
    for header, value in _PRIVATE_HEADERS.items():
        response[header] = value
    return response
