"""Role-tier enforcement from the 2026-07 field interviews.

* SP6 two-tier review — Reviewer recommends, Publication Assistant records the
  compliance check, the management tier (Editor in Chief et al.) takes the
  final decision.
* SP5 withdrawal authority — Knowledge Hub Manager / Editor in Chief / admin
  tier only; the KMO (Collection Manager) curates but does not withdraw.
* SP9 — M&E Officer reads repository analytics (and nothing else).
* SP4 — denied access attempts are recorded as AccessViolation rows and
  reviewable by the management tier.
"""

from __future__ import annotations

import pytest
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.urls import reverse

from apps.core.notifications.models import Notification
from apps.core.permissions.roles import UserRole
from apps.repository.documents import services
from apps.repository.documents.models import AccessViolation, Collection, Document, QARecord
from apps.repository.documents.permissions import can_download

User = get_user_model()


def _make_pdf(name: str) -> ContentFile:
    return ContentFile(
        b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<</Type/Catalog>>endobj\n"
        + name.encode()
        + b"\ntrailer<<>>\n%%EOF\n",
        name=name,
    )


def _user(email: str, role: str):
    u = User.objects.create_user(email=email, password="pw-test-1234")
    u.role = role
    u.save()
    return u


@pytest.fixture
def reviewer_user(db):
    return _user("rubric.reviewer@test.example", UserRole.REVIEWER)


@pytest.fixture
def pa_user(db):
    return _user("pub.assistant@test.example", UserRole.PUBLICATION_ASSISTANT)


@pytest.fixture
def eic_user(db):
    return _user("editor.chief@test.example", UserRole.EDITOR_IN_CHIEF)


@pytest.fixture
def khub_user(db):
    return _user("khub.manager@test.example", UserRole.KHUB_MANAGER)


@pytest.fixture
def kmo_user(db):
    return _user("kmo.officer@test.example", UserRole.KNOWLEDGE_MANAGEMENT_OFFICER)


@pytest.fixture
def mel_user(db):
    return _user("mel.officer@test.example", UserRole.MEL_OFFICER)


def _submit_doc(title, submitter_user, collection):
    doc = services.ingest_document(
        file=_make_pdf(f"{title}.pdf"),
        title=title,
        collection=collection,
        document_type=Document.DocumentType.THESIS,
        abstract="Abstract text.",
        submitted_by=submitter_user,
        actor=submitter_user,
    )
    services.submit_for_qa(doc, actor=submitter_user)
    return doc


# ── SP6 — Reviewer tier ─────────────────────────────────────────────────────


@pytest.mark.django_db
def test_reviewer_queue_scoped_to_own_assignments(
    client, submitter_user, manager_user, public_collection, reviewer_user
):
    doc = _submit_doc("Reviewer scoping", submitter_user, public_collection)
    qa = doc.qa_records.first()
    assert qa.reviewer == manager_user  # collection manager preferred

    client.force_login(reviewer_user)
    resp = client.get(reverse("repo_documents:qa_queue"))
    assert resp.status_code == 200
    assert "Reviewer scoping" not in resp.content.decode()

    qa.reviewer = reviewer_user
    qa.save(update_fields=["reviewer"])
    resp = client.get(reverse("repo_documents:qa_queue"))
    assert "Reviewer scoping" in resp.content.decode()


@pytest.mark.django_db
def test_reviewer_recommends_but_cannot_finalize(
    client, submitter_user, manager_user, public_collection, reviewer_user, eic_user
):
    doc = _submit_doc("Two tier", submitter_user, public_collection)
    qa = doc.qa_records.first()
    qa.reviewer = reviewer_user
    qa.save(update_fields=["reviewer"])

    client.force_login(reviewer_user)
    payload = {"decision": "approve", "comments": ""}
    payload.update({f"checklist_{c}": "on" for c, _ in QARecord.CHECKLIST_CRITERIA})

    # Direct POST to the final-decision endpoint is denied.
    resp = client.post(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}), payload)
    assert resp.status_code == 403

    # The recommendation endpoint records without changing document state.
    resp = client.post(reverse("repo_documents:qa_recommend", kwargs={"pk": qa.pk}), payload)
    assert resp.status_code == 302
    qa.refresh_from_db()
    doc = Document.objects.get(pk=doc.pk)
    assert qa.recommendation == QARecord.Decision.APPROVE
    assert qa.recommended_by == reviewer_user
    assert qa.decision == QARecord.Decision.PENDING
    assert doc.status == Document.Status.UNDER_QA

    # The Editor in Chief was notified for the final decision.
    assert Notification.objects.filter(
        recipient=eic_user, verb=Notification.Verb.REPO_QA_RECOMMENDED
    ).exists()


@pytest.mark.django_db
def test_recommend_approval_requires_full_checklist(
    submitter_user, manager_user, public_collection, reviewer_user
):
    doc = _submit_doc("Rubric gate", submitter_user, public_collection)
    qa = doc.qa_records.first()
    incomplete = services.full_qa_checklist()
    incomplete["oa_compliance"] = False
    with pytest.raises(ValidationError):
        services.recommend_qa(
            qa, recommendation="approve", checklist=incomplete, actor=reviewer_user
        )


@pytest.mark.django_db
def test_reviewer_cannot_open_unassigned_record(
    client, submitter_user, manager_user, public_collection, reviewer_user
):
    doc = _submit_doc("Unassigned record", submitter_user, public_collection)
    qa = doc.qa_records.first()
    assert qa.reviewer != reviewer_user
    client.force_login(reviewer_user)
    resp = client.get(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}))
    assert resp.status_code == 403


@pytest.mark.django_db
def test_eic_finalizes_after_recommendation(
    client, submitter_user, manager_user, public_collection, reviewer_user, eic_user
):
    doc = _submit_doc("EIC final", submitter_user, public_collection)
    qa = doc.qa_records.first()
    qa.reviewer = reviewer_user
    qa.save(update_fields=["reviewer"])
    services.recommend_qa(
        qa,
        recommendation="approve",
        checklist=services.full_qa_checklist(),
        actor=reviewer_user,
    )

    client.force_login(eic_user)
    resp = client.get(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}))
    assert resp.status_code == 200
    assert "Reviewer recommendation" in resp.content.decode()

    payload = {"decision": "approve", "comments": ""}
    payload.update({f"checklist_{c}": "on" for c, _ in QARecord.CHECKLIST_CRITERIA})
    resp = client.post(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}), payload)
    assert resp.status_code == 302
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.PUBLISHED


@pytest.mark.django_db
def test_reviewer_fallback_assignment_gets_object_perms(db, submitter_user, reviewer_user):
    # No collection managers and no repo_manager users → the Reviewer pool is
    # the assignment fallback, with per-object perms on the restricted doc.
    col = Collection.objects.create(
        slug="orphan-restricted",
        name="Orphan restricted",
        visibility=Collection.Visibility.RESTRICTED,
        qa_workflow_enabled=True,
    )
    doc = _submit_doc("Fallback assignment", submitter_user, col)
    qa = doc.qa_records.first()
    assert qa.reviewer == reviewer_user
    assert reviewer_user.has_perm("repository_documents.view_document", doc)
    assert reviewer_user.has_perm("repository_documents.download_document", doc)


# ── SP6 — Publication Assistant tier ────────────────────────────────────────


@pytest.mark.django_db
def test_publication_assistant_compliance_flow(
    client, submitter_user, manager_user, public_collection, pa_user
):
    doc = _submit_doc("Compliance flow", submitter_user, public_collection)
    qa = doc.qa_records.first()

    client.force_login(pa_user)
    # PA sees the whole pending queue (compliance sweep).
    resp = client.get(reverse("repo_documents:qa_queue"))
    assert resp.status_code == 200
    assert "Compliance flow" in resp.content.decode()

    # Flagging without notes is rejected.
    resp = client.post(
        reverse("repo_documents:qa_compliance", kwargs={"pk": qa.pk}),
        {"status": "flagged", "notes": ""},
    )
    qa.refresh_from_db()
    assert qa.compliance_status == QARecord.ComplianceStatus.PENDING

    resp = client.post(
        reverse("repo_documents:qa_compliance", kwargs={"pk": qa.pk}),
        {"status": "flagged", "notes": "Licence mismatch with OA mandate."},
    )
    assert resp.status_code == 302
    qa.refresh_from_db()
    assert qa.compliance_status == QARecord.ComplianceStatus.FLAGGED
    assert qa.compliance_checked_by == pa_user
    assert Notification.objects.filter(
        recipient=manager_user, verb=Notification.Verb.REPO_QA_COMPLIANCE_FLAGGED
    ).exists()

    # PA cannot take the final decision.
    payload = {"decision": "approve", "comments": ""}
    payload.update({f"checklist_{c}": "on" for c, _ in QARecord.CHECKLIST_CRITERIA})
    resp = client.post(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}), payload)
    assert resp.status_code == 403
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.UNDER_QA


# ── SP5 — withdrawal authority ──────────────────────────────────────────────


def _published_doc(submitter_user, manager_user, collection):
    doc = _submit_doc("Withdrawal target", submitter_user, collection)
    qa = doc.qa_records.first()
    services.decide_qa(
        qa, decision="approve", checklist=services.full_qa_checklist(), actor=manager_user
    )
    return Document.objects.get(pk=doc.pk)


@pytest.mark.django_db
def test_kmo_cannot_withdraw(client, submitter_user, manager_user, public_collection, kmo_user):
    doc = _published_doc(submitter_user, manager_user, public_collection)
    client.force_login(kmo_user)
    url = reverse("repo_documents:document_withdraw", kwargs={"uid": doc.document_uid})
    assert client.get(url).status_code == 403
    assert client.post(url, {"reason": "Trying anyway."}).status_code == 403
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.PUBLISHED


@pytest.mark.django_db
def test_khub_manager_can_withdraw(
    client, submitter_user, manager_user, public_collection, khub_user
):
    doc = _published_doc(submitter_user, manager_user, public_collection)
    client.force_login(khub_user)
    url = reverse("repo_documents:document_withdraw", kwargs={"uid": doc.document_uid})
    assert client.get(url).status_code == 200
    resp = client.post(url, {"reason": "Superseded by corrected edition."})
    assert resp.status_code == 302
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.WITHDRAWN


# ── SP9 — M&E Officer analytics access ──────────────────────────────────────


@pytest.mark.django_db
def test_mel_officer_reads_analytics_not_qa(client, mel_user):
    client.force_login(mel_user)
    assert client.get(reverse("repo_analytics:usage_dashboard")).status_code == 200
    assert client.get(reverse("repo_documents:qa_queue")).status_code == 403
    assert client.get(reverse("repo_documents:access_violation_list")).status_code == 403


# ── SP4 — access-violation trail ────────────────────────────────────────────


@pytest.mark.django_db
def test_denied_download_recorded_and_reviewable(
    client, submitter_user, manager_user, restricted_collection, sample_pdf
):
    doc = services.ingest_document(
        file=sample_pdf,
        title="Restricted paper",
        collection=restricted_collection,
        document_type=Document.DocumentType.TECHNICAL_REPORT,
        abstract="Sensitive.",
        submitted_by=submitter_user,
        actor=submitter_user,
    )
    services.submit_for_qa(doc, actor=submitter_user)
    services.decide_qa(
        doc.qa_records.first(),
        decision="approve",
        checklist=services.full_qa_checklist(),
        actor=manager_user,
    )

    outsider = _user("outsider@test.example", UserRole.ALUMNI)
    client.force_login(outsider)
    resp = client.get(
        reverse("repo_documents:document_download", kwargs={"uid": doc.document_uid})
    )
    assert resp.status_code == 403
    violation = AccessViolation.objects.get(document=doc, user=outsider)
    assert violation.action == AccessViolation.Action.DOWNLOAD

    # Management tier reviews the trail; the outsider cannot.
    assert client.get(reverse("repo_documents:access_violation_list")).status_code == 403
    client.force_login(manager_user)
    resp = client.get(reverse("repo_documents:access_violation_list"))
    assert resp.status_code == 200
    assert "outsider@test.example" in resp.content.decode()


# ── Hardening regressions (surfaced during extensive QA) ────────────────────


@pytest.mark.django_db
def test_finalize_endpoint_403_for_reviewer_even_with_invalid_form(
    client, submitter_user, manager_user, public_collection, reviewer_user
):
    """A non-finalizer POSTing an *invalid* finalize form must still 403 at
    dispatch — not fall through to form_invalid and render a misleading 200."""
    doc = _submit_doc("Invalid finalize", submitter_user, public_collection)
    qa = doc.qa_records.first()
    qa.reviewer = reviewer_user
    qa.save(update_fields=["reviewer"])
    client.force_login(reviewer_user)
    # Deliberately incomplete: decision only, no checklist → form would be invalid.
    resp = client.post(
        reverse("repo_documents:qa_review", kwargs={"pk": qa.pk}),
        {"decision": "approve"},
    )
    assert resp.status_code == 403
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.UNDER_QA


@pytest.mark.django_db
def test_compliance_check_blocked_after_withdraw(
    submitter_user, manager_user, public_collection, pa_user, khub_user
):
    """record_compliance_check must reject a document that has left UNDER_QA
    (e.g. withdrawn while its QARecord.decision was still pending)."""
    doc = _submit_doc("Withdraw then comply", submitter_user, public_collection)
    qa = doc.qa_records.first()
    # A source="*" withdraw can fire while the QA record is still pending.
    services.withdraw_document(doc, reason="Pulled early.", actor=khub_user)
    with pytest.raises(ValidationError):
        services.record_compliance_check(
            qa, status="passed", actor=pa_user
        )


@pytest.mark.django_db
def test_decide_qa_falls_back_to_reviewer_checklist_when_none(
    submitter_user, manager_user, public_collection, reviewer_user, eic_user
):
    """When a finalizer passes checklist=None, decide_qa should use the
    reviewer's persisted rubric rather than rebuilding it all-False."""
    doc = _submit_doc("Checklist carry", submitter_user, public_collection)
    qa = doc.qa_records.first()
    qa.reviewer = reviewer_user
    qa.save(update_fields=["reviewer"])
    services.recommend_qa(
        qa,
        recommendation="approve",
        checklist=services.full_qa_checklist(),
        actor=reviewer_user,
    )
    # No checklist passed — must fall back to the stored (fully-checked) rubric
    # and succeed rather than tripping the "all 6 must be checked" gate.
    services.decide_qa(qa, decision="approve", checklist=None, actor=eic_user)
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.PUBLISHED


@pytest.mark.django_db
def test_reviewer_object_perm_revoked_after_decision(db, submitter_user, reviewer_user):
    """Least-privilege — the per-object grant made to the assigned reviewer in
    submit_for_qa must be revoked once the QA decision is made, so a pure
    REVIEWER doesn't retain standing download on a rejected restricted doc."""
    col = Collection.objects.create(
        slug="revoke-restricted", name="Revoke restricted",
        visibility=Collection.Visibility.RESTRICTED, qa_workflow_enabled=True,
    )
    doc = _submit_doc("Grant revoke", submitter_user, col)
    qa = doc.qa_records.first()
    assert qa.reviewer == reviewer_user  # REVIEWER pool fallback (no managers)
    assert reviewer_user.has_perm("repository_documents.download_document", doc)

    services.decide_qa(qa, decision="reject", comments="no", actor=reviewer_user,
                       checklist=services.full_qa_checklist())
    doc = Document.objects.get(pk=doc.pk)
    assert doc.status == Document.Status.REJECTED
    # Grant gone — and the reviewer is not internal, so they now have no access.
    assert not reviewer_user.has_perm("repository_documents.view_document", doc)
    assert not reviewer_user.has_perm("repository_documents.download_document", doc)


@pytest.mark.django_db
def test_publication_assistant_sees_metadata_not_file(
    client, submitter_user, manager_user, restricted_collection, pa_user
):
    """Characterization of the accepted SP6 exposure: a PA works the QA queue
    for ALL pending docs (compliance sweep) and sees a restricted doc's
    metadata/abstract via the review workspace — but never the file bytes, and
    not via the normal detail/download paths (both still 403)."""
    doc = _submit_doc("PA metadata scope", submitter_user, restricted_collection)
    qa = doc.qa_records.first()
    # Not internal, no object grant → normal access paths deny the file.
    assert not can_download(pa_user, doc)
    client.force_login(pa_user)
    # The QA review workspace intentionally bypasses can_access for the queue.
    assert client.get(reverse("repo_documents:qa_review", kwargs={"pk": qa.pk})).status_code == 200
    # But the public detail + download endpoints stay closed to the PA.
    assert client.get(
        reverse("repo_documents:document_detail", kwargs={"uid": doc.document_uid})
    ).status_code == 403
    assert client.get(
        reverse("repo_documents:document_download", kwargs={"uid": doc.document_uid})
    ).status_code == 403


@pytest.mark.django_db
def test_nav_flags_by_persona(rf, reviewer_user, pa_user, mel_user, kmo_user):
    """nav_visibility() must expose the right repository nav tiers per role:
    reviewers/PAs get the QA link only, MEL officers get analytics only,
    KMO gets the full management set."""
    from apps.core.nav_context import nav_visibility

    def flags(user):
        req = rf.get("/dashboard/")
        req.user = user
        return nav_visibility(req)

    rv = flags(reviewer_user)
    assert rv["nav_repo_qa"] and not rv["nav_repo_manage"] and not rv["nav_repo_analytics"]

    pv = flags(pa_user)
    assert pv["nav_repo_qa"] and not pv["nav_repo_manage"] and not pv["nav_repo_analytics"]

    mv = flags(mel_user)
    assert mv["nav_repo_analytics"] and not mv["nav_repo_manage"] and not mv["nav_repo_qa"]

    kv = flags(kmo_user)
    assert kv["nav_repo_manage"] and kv["nav_repo_qa"] and kv["nav_repo_analytics"]
