"""§7 — Reviewer surface upgrade.

Covers the three new view-context invariants:

* ``ReviewerApplicationView`` exposes ``queue_position`` / ``queue_total``
  computed from the reviewer's open queue ordered by call close date.
* ``ReviewSubmitView`` exposes ``peer_distribution`` aggregated over
  submitted reviews on the same call, **excluding** the current reviewer.
* The dismissible reviewer-tips callout is wired into ``review_queue.html``
  with the Alpine + localStorage pattern so periodic users see it once and
  veterans can tuck it away.
"""
from datetime import timedelta
from decimal import Decimal

import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone

from apps.core.permissions.roles import UserRole
from apps.rims.grants.models import Application, GrantCall, Review

User = get_user_model()


def _make_call(slug_suffix="1", rubric=None, **overrides):
    defaults = dict(
        title=f"Test call {slug_suffix}",
        slug=f"reviewer-ui-call-{slug_suffix}",
        opens_at=timezone.now() - timedelta(days=2),
        closes_at=timezone.now() + timedelta(days=14),
        status=GrantCall.Status.PUBLISHED,
        scoring_rubric=rubric
        or [
            {"criterion": "Relevance", "max_points": 10},
            {"criterion": "Feasibility", "max_points": 10},
        ],
    )
    defaults.update(overrides)
    return GrantCall.objects.create(**defaults)


@pytest.fixture
def reviewer(db):
    u = User.objects.create_user(email="reviewer@example.com", password="pw")
    u.role = UserRole.REVIEWER
    u.save()
    return u


@pytest.fixture
def other_reviewer(db):
    u = User.objects.create_user(email="other-reviewer@example.com", password="pw")
    u.role = UserRole.REVIEWER
    u.save()
    return u


@pytest.fixture
def applicant(db):
    return User.objects.create_user(email="app1@example.com", password="pw")


@pytest.mark.django_db
def test_queue_position_orders_by_call_close_date(client, reviewer, applicant):
    """Reviewing N of M follows the same order the queue view uses
    (call closing date ascending), so the count moves predictably as the
    reviewer works through assignments."""
    call_a = _make_call(slug_suffix="early", closes_at=timezone.now() + timedelta(days=2))
    call_b = _make_call(slug_suffix="late", closes_at=timezone.now() + timedelta(days=30))
    call_c = _make_call(slug_suffix="middle", closes_at=timezone.now() + timedelta(days=10))

    reviews = []
    for call in (call_a, call_b, call_c):
        app = Application.objects.create(
            call=call,
            applicant=applicant,
            proposal="proposal",
            anonymized_code=f"A-{call.pk}",
        )
        reviews.append(
            Review.objects.create(
                application=app,
                reviewer=reviewer,
                due_at=timezone.now() + timedelta(days=7),
            )
        )

    client.force_login(reviewer)

    # Mid-window review (call_c, closes in 10 days) is position 2 of 3 in the
    # close-date ordering.
    response = client.get(
        reverse("rims_grants:review_application", args=[reviews[2].pk])
    )
    assert response.status_code == 200
    assert response.context["queue_position"] == 2
    assert response.context["queue_total"] == 3


@pytest.mark.django_db
def test_queue_position_excludes_submitted_reviews(client, reviewer, applicant):
    """Once a reviewer submits a review it drops out of the open queue, so
    the progress count reflects remaining work — not lifetime workload."""
    call_a = _make_call(slug_suffix="a", closes_at=timezone.now() + timedelta(days=3))
    call_b = _make_call(slug_suffix="b", closes_at=timezone.now() + timedelta(days=6))

    app_a = Application.objects.create(call=call_a, applicant=applicant, proposal="p")
    app_b = Application.objects.create(call=call_b, applicant=applicant, proposal="p")
    review_a = Review.objects.create(
        application=app_a,
        reviewer=reviewer,
        submitted_at=timezone.now(),  # already submitted
        total_score=Decimal("14"),
    )
    review_b = Review.objects.create(application=app_b, reviewer=reviewer)

    client.force_login(reviewer)
    response = client.get(
        reverse("rims_grants:review_application", args=[review_b.pk])
    )

    # review_b is the sole open assignment — position 1 of 1, not "of 2".
    assert response.context["queue_position"] == 1
    assert response.context["queue_total"] == 1

    # Submitted review has no progress anchor at all.
    response = client.get(
        reverse("rims_grants:review_application", args=[review_a.pk])
    )
    assert "queue_position" not in response.context


@pytest.mark.django_db
def test_peer_score_distribution_excludes_self(client, reviewer, other_reviewer, applicant):
    """The anonymised peer histogram on review_submit must omit the current
    reviewer's own scores so the comparison is genuinely external."""
    call = _make_call(slug_suffix="dist")
    # Distinct applicants per application — Application has a uniq
    # (call_id, applicant_id) constraint so we can't reuse a single user.
    applicants = [applicant] + [
        User.objects.create_user(email=f"dist-app-{i}@example.com", password="pw")
        for i in range(1, 3)
    ]
    apps = [
        Application.objects.create(
            call=call,
            applicant=applicants[i],
            proposal=f"p{i}",
            anonymized_code=f"A-{i}",
        )
        for i in range(3)
    ]
    # Reviewer-under-test has a draft (in-flight) review for app[0].
    pending = Review.objects.create(application=apps[0], reviewer=reviewer)
    # …and a *submitted* one for app[1] that should still be excluded from
    # peer distribution when they're viewing their own submit page.
    Review.objects.create(
        application=apps[1],
        reviewer=reviewer,
        submitted_at=timezone.now(),
        total_score=Decimal("19"),
    )
    # Two genuinely-peer reviews from other_reviewer.
    Review.objects.create(
        application=apps[0],
        reviewer=other_reviewer,
        submitted_at=timezone.now(),
        total_score=Decimal("12"),
    )
    Review.objects.create(
        application=apps[1],
        reviewer=other_reviewer,
        submitted_at=timezone.now(),
        total_score=Decimal("18"),
    )

    client.force_login(reviewer)
    response = client.get(reverse("rims_grants:review_submit", args=[pending.pk]))
    assert response.status_code == 200
    dist = response.context["peer_distribution"]
    assert dist is not None
    # Only the two peer reviews count — reviewer's own submitted review on
    # the same call is excluded.
    assert dist["peer_count"] == 2
    assert sum(b["count"] for b in dist["buckets"]) == 2
    # Buckets cover 0–20 (rubric is 10 + 10), in 5 equal bands.
    assert dist["max_total"] == Decimal("20")
    assert len(dist["buckets"]) == 5


@pytest.mark.django_db
def test_peer_distribution_handles_no_peers(client, reviewer, applicant):
    """A reviewer with no submitted peer reviews still gets a distribution
    object so the template can render the "you're first" empty state."""
    call = _make_call(slug_suffix="first")
    app = Application.objects.create(
        call=call, applicant=applicant, proposal="p", anonymized_code="ANON-0"
    )
    review = Review.objects.create(application=app, reviewer=reviewer)

    client.force_login(reviewer)
    response = client.get(reverse("rims_grants:review_submit", args=[review.pk]))
    dist = response.context["peer_distribution"]
    assert dist is not None
    assert dist["peer_count"] == 0


@pytest.mark.django_db
def test_review_queue_renders_dismissible_tips_panel(client, reviewer, applicant):
    """The reviewer-tips panel must be wired with the Alpine + localStorage
    dismissal hook so periodic reviewers can hide it after the first session."""
    client.force_login(reviewer)
    response = client.get(reverse("rims_grants:review_queue"))
    body = response.content.decode()
    assert "rims_reviewer_tips_dismissed_v1" in body
    assert "reviewer-tips" in body
