"""Regression suite for 4 P0 bugs found by live browser QA (agent-browser)
against the grants module that pytest's existing suite missed because it
exercised service-layer functions directly and never drove the real
view/form/template layer.

Bug 1 — AwardAmendmentForm.Meta.fields included the protected django-fsm
`status` field. ModelForm._post_clean() calls construct_instance(), which
does setattr(instance, "status", ...) for every field in Meta.fields, and
django-fsm raises AttributeError ("Direct status modification is not
allowed") on any direct set — so is_valid() (called before the view/service
layer ever runs) crashed with a 500 on every amendment create/resubmit POST,
regardless of which dropdown value was submitted.

Bug 2 — Two module-level ``ProgramDirectorMixin`` classes exist in
apps/rims/grants/views.py. The earlier one (which every approve/reject view
defined before it in the file actually binds to, per Python's class-statement
name resolution) paired ``allowed_roles=(ADMIN, PROGRAM_DIRECTOR)`` with
``rims_permissions=(MANAGE_CALLS,)``. RimsAccessMixin.dispatch() ORs those
together when both are set, and MANAGE_CALLS is the exact permission every
Grants Manager holds via GrantsManagerMixin — so any GM's permission check
satisfied the gate and bypassed the Programme-Director-only role
restriction entirely, for award-amendment, award-cancellation, and
cohort-addendum approve/reject alike.
"""
from __future__ import annotations

from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import patch

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,
    Award,
    AwardAmendment,
    AwardCancellationRequest,
    CohortAddendum,
    GrantCall,
)
from apps.rims.grants.services import (
    award_application,
    create_award_cancellation,
    request_cohort_addendum,
    shortlist_application,
    submit_application,
    submit_cancellation_for_review,
    submit_cohort_addendum_for_review,
)

User = get_user_model()


@pytest.fixture(autouse=True)
def _no_async_dispatch():
    with patch(
        "apps.core.notifications.services.dispatch_notification_channel.delay"
    ) as fake:
        yield fake


@pytest.fixture
def program_director_user(db):
    u = User.objects.create_user(email="director.viewbugs@example.com", password="pw-viewbugs")
    u.role = UserRole.PROGRAM_DIRECTOR
    u.save()
    return u


def _awarded(applicant_user, institution, *, suffix, amount=Decimal("1000")) -> Award:
    call = GrantCall.objects.create(
        title=f"View-bug call {suffix}",
        slug=f"viewbug-{suffix}-{int(timezone.now().timestamp() * 1000) % 10_000_000}",
        opens_at=timezone.now() - timedelta(days=1),
        closes_at=timezone.now() + timedelta(days=10),
        status=GrantCall.Status.PUBLISHED,
    )
    app = Application.objects.create(call=call, applicant=applicant_user, institution=institution)
    submit_application(app)
    app = Application.objects.get(pk=app.pk)
    shortlist_application(app)
    award_application(app, amount, date.today() + timedelta(days=180), narrative="")
    return Application.objects.get(pk=app.pk).award


# ---------------------------------------------------------------------------
# Bug 1 — AwardAmendmentForm 500 on submission (real view/form/template path).
# ---------------------------------------------------------------------------


@pytest.mark.django_db
def test_amendment_create_form_has_no_protected_fsm_field():
    """Structural guard against the regression: the create/resubmit form must
    never bind AwardAmendment.status (protected FSMField) or rejection_reason
    (a decision-time field the requester never sets)."""
    from apps.rims.grants.forms import AwardAmendmentForm

    form = AwardAmendmentForm()
    assert "status" not in form.fields
    assert "rejection_reason" not in form.fields


@pytest.mark.django_db
def test_gm_can_post_amendment_create_view_without_500(client, applicant_user, grants_manager_user, institution):
    """Drives the real AwardAmendmentCreateView POST — this is the exact
    request the browser QA reproduced the 500 with."""
    award = _awarded(applicant_user, institution, suffix="create-no500")
    client.force_login(grants_manager_user)

    resp = client.post(
        reverse("rims_grants:award_amendment_create", kwargs={"pk": award.pk}),
        {
            "change_summary": "Extend the project end date by three months.",
            "justification": "Field access delayed by the rainy season.",
            "previous_terms_json": "{}",
            "new_terms_json": '{"project_end_date": "2027-06-30"}',
        },
    )

    assert resp.status_code in (200, 302), f"expected no 500, got {resp.status_code}: {resp.content[:500]!r}"
    amendment = AwardAmendment.objects.get(award=award)
    assert amendment.status == AwardAmendment.Status.REQUESTED
    assert amendment.change_summary == "Extend the project end date by three months."


@pytest.mark.django_db
def test_amendment_resubmit_view_without_500(client, applicant_user, grants_manager_user, program_director_user, institution):
    """The resubmit view reuses the same form fields via raw POST names, but
    guard the create->reject->resubmit round trip end-to-end through the
    real views to be safe against any future re-coupling to the ModelForm."""
    from apps.rims.grants.services import create_award_amendment

    award = _awarded(applicant_user, institution, suffix="resubmit-no500")
    amendment = create_award_amendment(
        award,
        change_summary="Increase award amount",
        justification="Additional co-funding secured.",
        new_terms={"amount": "1500.00"},
        actor=grants_manager_user,
    )
    client.force_login(program_director_user)
    client.post(
        reverse("rims_grants:award_amendment_reject", kwargs={"pk": amendment.pk}),
        {"reason": "Needs more detail on the funding source."},
    )
    amendment = AwardAmendment.objects.get(pk=amendment.pk)
    assert amendment.status == AwardAmendment.Status.REJECTED

    client.force_login(grants_manager_user)
    resp = client.post(
        reverse("rims_grants:award_amendment_resubmit", kwargs={"pk": amendment.pk}),
        {
            "change_summary": "Increase award amount (revised)",
            "justification": "Confirmed co-funding letter attached.",
            "new_terms_json": '{"amount": "1400.00"}',
        },
    )
    assert resp.status_code in (200, 302), f"expected no 500, got {resp.status_code}: {resp.content[:500]!r}"
    amendment = AwardAmendment.objects.get(pk=amendment.pk)
    assert amendment.status == AwardAmendment.Status.REQUESTED


# ---------------------------------------------------------------------------
# Bug 2 — ProgramDirectorMixin RBAC bypass via shared MANAGE_CALLS permission.
# ---------------------------------------------------------------------------


@pytest.mark.django_db
def test_gm_cannot_approve_cancellation_via_manage_calls_permission(
    client, applicant_user, grants_manager_user, program_director_user, institution
):
    award = _awarded(applicant_user, institution, suffix="cancel-approve-bypass")
    cancellation = create_award_cancellation(award, reason="No longer needed.", actor=grants_manager_user)
    submit_cancellation_for_review(cancellation, actor=grants_manager_user)

    client.force_login(grants_manager_user)
    resp = client.post(
        reverse("rims_grants:award_cancellation_approve", kwargs={"pk": cancellation.pk}),
        {"decision_note": "Approving my own request."},
    )

    assert resp.status_code == 403
    cancellation = AwardCancellationRequest.objects.get(pk=cancellation.pk)
    assert cancellation.status == AwardCancellationRequest.Status.DIRECTOR_REVIEW
    award = Award.objects.get(pk=award.pk)
    assert award.status != Award.Status.CANCELLED


@pytest.mark.django_db
def test_gm_cannot_reject_cancellation_via_manage_calls_permission(
    client, applicant_user, grants_manager_user, institution
):
    award = _awarded(applicant_user, institution, suffix="cancel-reject-bypass")
    cancellation = create_award_cancellation(award, reason="No longer needed.", actor=grants_manager_user)
    submit_cancellation_for_review(cancellation, actor=grants_manager_user)

    client.force_login(grants_manager_user)
    resp = client.post(
        reverse("rims_grants:award_cancellation_reject", kwargs={"pk": cancellation.pk}),
        {"decision_note": "Rejecting my own request."},
    )

    assert resp.status_code == 403
    cancellation = AwardCancellationRequest.objects.get(pk=cancellation.pk)
    assert cancellation.status == AwardCancellationRequest.Status.DIRECTOR_REVIEW


@pytest.mark.django_db
def test_program_director_can_still_approve_cancellation(
    client, applicant_user, grants_manager_user, program_director_user, institution
):
    """Regression guard: fixing the bypass must not also break the PD's own
    (correct) approval path."""
    award = _awarded(applicant_user, institution, suffix="cancel-approve-pd-ok")
    cancellation = create_award_cancellation(award, reason="No longer needed.", actor=grants_manager_user)
    submit_cancellation_for_review(cancellation, actor=grants_manager_user)

    client.force_login(program_director_user)
    resp = client.post(
        reverse("rims_grants:award_cancellation_approve", kwargs={"pk": cancellation.pk}),
        {"decision_note": "Approved."},
    )
    assert resp.status_code == 302
    cancellation = AwardCancellationRequest.objects.get(pk=cancellation.pk)
    assert cancellation.status == AwardCancellationRequest.Status.APPROVED


@pytest.mark.django_db
def test_gm_cannot_approve_cohort_addendum_via_manage_calls_permission(
    client, applicant_user, grants_manager_user, institution
):
    award = _awarded(applicant_user, institution, suffix="cohort-approve-bypass", amount=Decimal("4000"))
    addendum = request_cohort_addendum(
        award,
        reason="Adding a late cohort.",
        new_cohort_terms={"label": "2027 cohort", "target_participants": 10, "budget_allocation": "500"},
        actor=grants_manager_user,
    )
    submit_cohort_addendum_for_review(addendum, actor=grants_manager_user)

    client.force_login(grants_manager_user)
    resp = client.post(
        reverse("rims_grants:cohort_addendum_approve", kwargs={"pk": addendum.pk}),
        {"decision_notes": "Approving my own addendum."},
    )

    assert resp.status_code == 403
    addendum = CohortAddendum.objects.get(pk=addendum.pk)
    assert addendum.status == CohortAddendum.Status.DIRECTOR_REVIEW


@pytest.mark.django_db
def test_gm_cannot_reject_cohort_addendum_via_manage_calls_permission(
    client, applicant_user, grants_manager_user, institution
):
    award = _awarded(applicant_user, institution, suffix="cohort-reject-bypass", amount=Decimal("4000"))
    addendum = request_cohort_addendum(
        award,
        reason="Adding a late cohort.",
        new_cohort_terms={"label": "2027 cohort", "target_participants": 10, "budget_allocation": "500"},
        actor=grants_manager_user,
    )
    submit_cohort_addendum_for_review(addendum, actor=grants_manager_user)

    client.force_login(grants_manager_user)
    resp = client.post(
        reverse("rims_grants:cohort_addendum_reject", kwargs={"pk": addendum.pk}),
        {"decision_notes": "Rejecting my own addendum."},
    )

    assert resp.status_code == 403
    addendum = CohortAddendum.objects.get(pk=addendum.pk)
    assert addendum.status == CohortAddendum.Status.DIRECTOR_REVIEW
