"""Load demo RIMS data for local development (institutions, users, one published call)."""

from datetime import date, timedelta
from decimal import Decimal

from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone

from apps.core.authentication.models import Institution, UserProfile
from apps.core.countries import to_country_name
from apps.core.permissions.roles import UserRole
from apps.rims.finance.models import (
    Budget,
    BudgetLine,
    BudgetRevision,
    BudgetRevisionLine,
    DisbursementExecution,
    DisbursementReconciliation,
    DisbursementRequest,
    ExchangeRate,
    Expenditure,
    FundingSource,
    PaymentSchedule,
)
from apps.rims.grants.models import (
    AcademicProgressReport,
    Application,
    ApplicationDocument,
    Award,
    AwardAgreement,
    AwardTranche,
    ConflictOfInterest,
    EligibilityRule,
    FundingRecord,
    GrantBudgetLine,
    GrantCall,
    GrantCallDocument,
    InterviewOutcome,
    InterviewSchedule,
    MilestoneEvidence,
    ReportingCalendarEntry,
    RequiredDocument,
    Review,
    StrategicObjective,
    VerificationRecord,
)
from apps.rims.grants.funding import approve_funding_record, submit_funding_for_approval
from apps.rims.grants.services import (
    assign_reviewer,
    award_application,
    create_or_update_interview,
    publish_call,
    record_interview_outcome,
    record_verification,
    shortlist_application,
    submit_application,
    submit_review,
)
from apps.rims.operations.models import (
    Donor,
    Member,
    MOU,
    Partner,
    ReminderConfig,
    RetentionPolicy,
    ReviewerProfile,
    VarianceAlertConfig,
)
from apps.rims.projects.models import (
    Beneficiary,
    BeneficiaryAttendance,
    Deliverable,
    Milestone,
    MilestoneAudit,
    Project,
    ProjectBudgetLine,
    RiskFlag,
)
from apps.rims.scholarships.models import (
    GraduationRecord,
    ProgressReport,
    Scholar,
    Scholarship,
    Stipend,
)

User = get_user_model()

# Quill-safe HTML for seeded rich text (description, guidelines, proposals, narratives, etc.).
_LOREM_BODY = (
    "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut "
    "labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris "
    "nisi ut aliquip ex ea commodo consequat.</p>"
    "<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla "
    "pariatur. <strong>Excepteur sint occaecat</strong> cupidatat non proident, sunt in culpa qui "
    "officia deserunt mollit anim id est laborum.</p>"
    "<ul><li>Alignment with RUFORUM network priorities and member institutions</li>"
    "<li>Clear milestones and measurable outcomes</li>"
    "<li>Institutional endorsement where the call requires it</li></ul>"
    "<p>Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis eu lacinia "
    "dictum, libero mi faucibus purus, vitae placerat pede quam sed neque.</p>"
    "<p><em>Additional context for UI testing:</em> long prose exercises truncation, scroll regions, "
    "and typography in call detail, apply flows, and manager review screens.</p>"
)

_LOREM_GUIDELINES = (
    "<p>Follow these steps to complete your submission:</p>"
    "<ol><li>Read the call overview and eligibility rules in full.</li>"
    "<li>Gather every document listed in the checklist before starting the form.</li>"
    "<li>Draft your proposal in the rich-text editor; you may paste from a word processor.</li>"
    "<li>Upload PDFs where possible; ensure file names identify the document type.</li>"
    "<li>Submit before the deadline; the system rejects late applications automatically.</li></ol>"
    "<p>Suspendisse vel felis. Ut lorem lorem, interdum eu, faucibus nec, rhoncus ut, sapien. Maecenas "
    "consequat vestibulum ligula. Integer placerat tristique quam.</p>"
    "<p>Direct policy questions to your institutional grants office or the RUFORUM secretariat through "
    "official channels only.</p>"
)

_LOREM_PROPOSAL = (
    "<p><strong>Objectives</strong></p><p>We aim to strengthen research capacity while delivering "
    "measurable outcomes for smallholder communities. Lorem ipsum dolor sit amet, consectetur "
    "adipiscing elit.</p>"
    "<p><strong>Methods</strong></p><p>Mixed methods including surveys, focus groups, and participatory "
    "trials. <strong>Timeline:</strong> twelve months with quarterly checkpoints.</p>"
    "<ul><li>Phase 1: baseline and stakeholder mapping</li><li>Phase 2: intervention pilots</li>"
    "<li>Phase 3: synthesis and dissemination</li></ul>"
    "<p><strong>Expected impact</strong></p><p>Improved adoption of climate-smart practices and peer-"
    "reviewed outputs. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>"
)


def _html_rich_description(heading: str) -> str:
    return f"<p><strong>{heading}</strong></p>{_LOREM_BODY}"


def _html_rich_guidelines(heading: str) -> str:
    return f"<p><strong>{heading}</strong></p><p><em>Read every section before you begin.</em></p>{_LOREM_GUIDELINES}"


def _html_proposal(title: str) -> str:
    return f"<p><strong>{title}</strong></p>{_LOREM_PROPOSAL}"


# (description heading, guidelines heading) per seeded call slug — browser / Quill fixtures.
CALL_RICH_HEADINGS: dict[str, tuple[str, str]] = {
    "demo-seed-call": ("Demo seed grant call — overview", "How to apply — demo seed call"),
    "e2e-pilot-call": ("Pilot cohort 2026 — resilience fund", "Application guidelines — pilot cohort"),
    "e2e-reviewer-call": ("E2E reviewer queue call", "Applicant instructions — reviewer queue E2E"),
    "e2e-eligibility-call": ("E2E eligibility — PhD required (automated screening)", "Guidelines — eligibility E2E"),
    "e2e-interview-call": ("E2E interview-before-award", "Guidelines — interview gate E2E"),
    "e2e-conflict-call": ("E2E reviewer recommendation conflict", "Guidelines — conflict resolution E2E"),
    "e2e-blind-call": ("E2E blind review call", "Guidelines — blind review E2E"),
    "e2e-po-screening-call": ("E2E programme officer screening call", "Guidelines — PO screening E2E"),
}

# first_name, last_name, phone (E.164-style, <=32 chars)
DEMO_USER_IDENTITIES: dict[str, tuple[str, str, str]] = {
    "system.admin@demo.local": ("Linet", "Ssemwanga", "+256700000012"),
    "program.manager@demo.local": ("Samuel", "Kagoya", "+256700000013"),
    "program.director@demo.local": ("Juliet", "Achieng", "+254700000014"),
    "finance.director@demo.local": ("Moses", "Nabwire", "+256700000015"),
    # Phase 1 personas — SRS §5.1/§5.4 actors missing from the named-seed set.
    "finance.manager@demo.local": ("Aline", "Mukamana", "+250700000016"),
    "program.coordinator@demo.local": ("Brenda", "Nansubuga", "+256700000017"),
    "project.manager@demo.local": ("Mwangi", "Karanja", "+254700000018"),
    "programme.officer@demo.local": ("Halima", "Mwakasege", "+255700000019"),
    "grants.manager@demo.local": ("Nakato", "Mirembe", "+256700000001"),
    "reviewer@demo.local": ("James", "Okello", "+256700000002"),
    "reviewer2@demo.local": ("Sarah", "Njeri", "+254700000003"),
    "scholar@demo.local": ("Amina", "Kiprop", "+256700000004"),
    "finance@demo.local": ("David", "Mwangi", "+256700000005"),
    "mel@demo.local": ("Grace", "Wanjiru", "+254700000006"),
    "award.recipient@demo.local": ("Peter", "Semakula", "+256700000007"),
    "eligibility.applicant@demo.local": ("Elena", "Vance", "+256700000008"),
    "interview.applicant@demo.local": ("Tom", "Bwire", "+256700000009"),
    "conflict.applicant@demo.local": ("Ruth", "Adong", "+256700000010"),
    "blind.applicant@demo.local": ("Chen", "Wei", "+256700000011"),
}

# All grant calls owned by this command — used for --reset (CASCADE wipes apps, awards, finance, projects).
DEMO_GRANT_CALL_SLUGS = (
    "demo-seed-call",
    "e2e-pilot-call",
    "e2e-reviewer-call",
    "e2e-eligibility-call",
    "e2e-interview-call",
    "e2e-conflict-call",
    "e2e-blind-call",
    # Phase 1 — PO screening fixture call (programme_officer_screening_enabled=True).
    "e2e-po-screening-call",
    # Legacy slug from older seeds (duplicate reviewer-queue fixture) — removed on --reset.
    "reviewer-queue-e2e-call",
)

# PRD 6.1 — call document checklists (label, description max 255, is_required).
DOCS_STANDARD = (
    (
        "CV / Résumé",
        "Current CV (≈2 pages): education, employment, publications, and contact details. Use PDF if possible.",
        True,
    ),
    (
        "Research proposal",
        "Objectives, methods, timeline, expected outcomes, and budget outline aligned with the call rubric.",
        True,
    ),
    (
        "Reference letter",
        "At least one signed letter from an academic or professional referee familiar with your recent work.",
        True,
    ),
)
DOCS_STANDARD_LIGHT = (
    (
        "CV / Résumé",
        "Two-page CV summarizing qualifications relevant to this pilot call.",
        True,
    ),
    (
        "Research proposal",
        "Short proposal (recommended 3–5 pages) covering aims, methods, and impact for resilience research.",
        True,
    ),
)
DOCS_PHD_ELIGIBILITY = (
    (
        "CV / Résumé",
        "Full CV including publications list; PDF preferred for screening and reviewer assignment.",
        True,
    ),
    (
        "Research proposal",
        "Max 3 pages: background, methodology, milestones, and alignment with PhD-level expectations.",
        True,
    ),
    (
        "Evidence of doctoral qualification",
        "Official PhD certificate or registrar letter; used with automated degree eligibility screening.",
        True,
    ),
)


class Command(BaseCommand):
    help = (
        "Create demo institution, role users, and a published grant call (idempotent by slug). "
        "Use --reset to delete seeded grant calls (and cascaded RIMS rows) before re-inserting."
    )

    def add_arguments(self, parser):
        parser.add_argument(
            "--password",
            default="password123",
            help="Password for seeded non-staff users (change immediately in real envs).",
        )
        parser.add_argument(
            "--comprehensive",
            action="store_true",
            help=(
                "Extra calls, applications, reviewer assignment, award+budget+pending disbursement, "
                "PRD browser fixtures (eligibility, interview gate, reviewer conflict, blind review, "
                "dashboard milestones, MEL/reviewer2 users) — idempotent."
            ),
        )
        parser.add_argument(
            "--reset",
            action="store_true",
            help=(
                "Delete all demo grant calls (see DEMO_GRANT_CALL_SLUGS in seed_rims.py) and dependent "
                "data, then seed again. Demo users and the demo institution are kept."
            ),
        )
        parser.add_argument(
            "--volume",
            choices=("minimal", "demo", "heavy"),
            default="demo",
            help="`heavy` implies --comprehensive.",
        )

    @transaction.atomic
    def handle(self, *args, **options):
        password = options["password"]
        # `heavy` implies the comprehensive fixture suite.
        if options.get("volume") in ("demo", "heavy"):
            options["comprehensive"] = True
        comprehensive = options["comprehensive"]
        if options["reset"]:
            demo_calls = GrantCall.objects.filter(slug__in=DEMO_GRANT_CALL_SLUGS)
            if not demo_calls.exists():
                self.stdout.write("Reset: no demo grant calls to remove (slugs already absent).")
            else:
                deleted, breakdown = demo_calls.delete()
                detail = ", ".join(f"{k}: {v}" for k, v in breakdown.items() if v)
                self.stdout.write(self.style.WARNING(f"Reset: removed {deleted} rows ({detail})"))

            # Cascading GrantCall.delete() leaves orphan Notification rows whose
            # action_url still points at deleted award/application PKs. Scrub
            # the RIMS-grants slice so the fresh seed produces a clean feed.
            from apps.core.notifications.models import Notification
            notif_deleted, _ = Notification.objects.filter(
                action_url__startswith="/rims/grants/"
            ).delete()
            if notif_deleted:
                self.stdout.write(self.style.WARNING(
                    f"Reset: wiped {notif_deleted} RIMS-grants Notification row(s)."
                ))

        inst, _ = Institution.objects.get_or_create(
            name="RUFORUM Demo University",
            defaults={
                "country": to_country_name("UG"),
                "is_member": True,
            },
        )

        gm, _ = User.objects.get_or_create(
            email="grants.manager@demo.local",
            defaults={
                "role": UserRole.GRANTS_MANAGER,
                "institution": inst,
            },
        )
        gm.set_password(password)
        gm.role = UserRole.GRANTS_MANAGER
        gm.institution = inst
        gm.save()

        rev, _ = User.objects.get_or_create(
            email="reviewer@demo.local",
            defaults={"role": UserRole.REVIEWER, "institution": inst},
        )
        rev.set_password(password)
        rev.role = UserRole.REVIEWER
        rev.save()

        scholar, _ = User.objects.get_or_create(
            email="scholar@demo.local",
            defaults={"role": UserRole.SCHOLAR, "institution": inst},
        )
        scholar.set_password(password)
        scholar.role = UserRole.SCHOLAR
        scholar.institution = inst
        scholar.save()

        closes = timezone.now() + timedelta(days=60)
        call, _created = GrantCall.objects.get_or_create(
            slug="demo-seed-call",
            defaults={
                "title": "Demo seed grant call",
                "description": "Seeded call for UI testing.",
                "guidelines": "Use only in development.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": closes,
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "reviewers_per_application": 2,
                "review_score_threshold": Decimal("60.00"),
                "review_deadline_days": 14,
                "verification_required": False,
                "scoring_rubric": [
                    {"criterion": "Scientific merit", "max_points": 40},
                    {"criterion": "Impact", "max_points": 30},
                    {"criterion": "Feasibility", "max_points": 30},
                ],
            },
        )
        review_updates = []
        if call.reviewers_per_application != 2:
            call.reviewers_per_application = 2
            review_updates.append("reviewers_per_application")
        if call.review_score_threshold != Decimal("60.00"):
            call.review_score_threshold = Decimal("60.00")
            review_updates.append("review_score_threshold")
        if call.review_deadline_days != 14:
            call.review_deadline_days = 14
            review_updates.append("review_deadline_days")
        if review_updates:
            review_updates.append("updated_at")
            call.save(update_fields=review_updates)
        call.reviewer_pool.add(gm, rev)
        if call.status == GrantCall.Status.DRAFT:
            call.created_by = gm
            call.save(update_fields=["created_by", "updated_at"])
            self._ensure_publish_prerequisites(call)
            publish_call(call, auto_close_acknowledged=True)
        self._ensure_required_documents(call, DOCS_STANDARD)
        self._sync_grant_call_html(call)

        if comprehensive:
            self._seed_comprehensive(inst, gm, rev, scholar, password)
        else:
            partner, mou, _ = self._ensure_demo_ecosystem(inst, gm)
            self._wire_grant_calls_ecosystem(partner, mou)
            self._scope_demo_calls_to_institution(inst, call.slug)

        self._enrich_demo_user_directory()

        reset_done = options["reset"]
        self.stdout.write(
            self.style.SUCCESS(
                f"Seed OK — call '{call.slug}' published. "
                f"Log in as grants.manager@demo.local / reviewer@demo.local / scholar@demo.local "
                f"(default password password123, or value of --password)."
                + (" Demo grant calls were reset before seeding." if reset_done else "")
                + (
                    " Comprehensive E2E dataset — see AGENTS.md for extra demo accounts and call slugs."
                    if comprehensive
                    else ""
                )
            )
        )

    def _ensure_demo_user(self, email: str, role: str, institution, password: str):
        user, _ = User.objects.get_or_create(
            email=email,
            defaults={"role": role, "institution": institution},
        )
        user.set_password(password)
        user.role = role
        user.institution = institution
        user.save()
        return user

    def _publish_if_draft(self, gc: GrantCall) -> None:
        if gc.status == GrantCall.Status.DRAFT:
            self._ensure_publish_prerequisites(gc)
            publish_call(gc, auto_close_acknowledged=True)

    def _ensure_publish_prerequisites(self, call: GrantCall) -> None:
        """NFRFA007/008 + FRFA-CS016 — eligibility rule + every required GrantCallDocument
        doc_kind for the call's call_type, so publish_call() passes validation."""
        from apps.rims.grants.constants import CALL_TYPE_REQUIRED_ADMIN_DOCS

        if not call.eligibility_rules.exists():
            EligibilityRule.objects.create(
                call=call,
                rule_type=EligibilityRule.RuleType.NATIONALITY,
                params={"countries": []},
                is_blocking=True,
            )

        # Drop a placeholder GrantCallDocument for every required doc_kind that's
        # missing. Default to just "guidelines" when the call_type has no entry.
        required_kinds = CALL_TYPE_REQUIRED_ADMIN_DOCS.get(
            call.call_type, ("guidelines",)
        )
        kind_labels = dict(GrantCallDocument.DocKind.choices)
        existing_kinds = set(
            call.documents.filter(superseded=False).values_list("doc_kind", flat=True)
        )
        for idx, kind in enumerate(required_kinds):
            if kind in existing_kinds:
                continue
            label = kind_labels.get(kind, kind.replace("_", " ").title())
            GrantCallDocument.objects.create(
                call=call,
                label=f"{label} (seed)",
                file=ContentFile(
                    b"%PDF-1.4\n1 0 obj<<>>endobj trailer<<>>\n%%EOF",
                    name=f"seed-{kind}.pdf",
                ),
                doc_kind=kind,
                is_guideline=(kind == GrantCallDocument.DocKind.GUIDELINES),
                sort_order=idx,
            )

    def _ensure_required_documents(self, call: GrantCall, specs: tuple[tuple[str, str, bool], ...]) -> None:
        """Idempotent checklist rows per call (PRD 6.1 — required documents on calls)."""
        for label, description, is_required in specs:
            doc = call.required_documents.filter(label=label).first()
            if not doc:
                RequiredDocument.objects.create(
                    call=call,
                    label=label,
                    description=description,
                    is_required=is_required,
                    sort_order=call.required_documents.count(),
                )
            elif doc.description != description or doc.is_required != is_required:
                doc.description = description
                doc.is_required = is_required
                doc.save(update_fields=["description", "is_required"])

    def _sync_grant_call_html(self, gc: GrantCall) -> None:
        pair = CALL_RICH_HEADINGS.get(gc.slug)
        if not pair:
            return
        dh, gh = pair
        desc = _html_rich_description(dh)
        guide = _html_rich_guidelines(gh)
        if gc.description == desc and gc.guidelines == guide:
            return
        gc.description = desc
        gc.guidelines = guide
        gc.save(update_fields=["description", "guidelines", "updated_at"])

    def _sync_application_proposal(self, app: Application, title: str) -> None:
        body = _html_proposal(title)
        if app.proposal == body:
            return
        app.proposal = body
        app.save(update_fields=["proposal", "updated_at"])

    def _enrich_demo_user_directory(self) -> None:
        for email, (fn, ln, phone) in DEMO_USER_IDENTITIES.items():
            user = User.objects.filter(email=email).first()
            if not user:
                continue
            full = f"{fn} {ln}".strip()
            bio = (
                f"{full} is a seeded demo persona for RUFORUM IILMP RIMS browser testing. "
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor "
                "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud "
                "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
            )
            profile = user.profile
            user.first_name = fn
            user.last_name = ln
            user.phone = phone
            user.email_verified = True
            user.save(update_fields=["first_name", "last_name", "phone", "email_verified"])
            if profile.bio != bio:
                profile.bio = bio
                profile.save(update_fields=["bio"])

    def _set_profile_screening(self, user, *, degree: str, country: str | None = "UG"):
        """Align demo profiles with eligibility automation (degree + optional country)."""
        profile = user.profile
        updates = []
        if profile.degree_level != degree:
            profile.degree_level = degree
            updates.append("degree_level")
        country_name = to_country_name(country) if country is not None else None
        if country_name is not None and profile.country != country_name:
            profile.country = country_name
            updates.append("country")
        if updates:
            profile.save(update_fields=updates)

    def _ensure_demo_ecosystem(self, inst, gm):
        """Operations + finance master data for cross-module FK wiring (partner, MOU, funding source)."""
        rich_partner_notes = _html_rich_description("Partner notes — Demo Funder Consortium (operations E2E)")
        partner, _ = Partner.objects.get_or_create(
            name="Demo Funder Consortium (IILMP seed)",
            defaults={
                "partner_type": Partner.PartnerType.FUNDER,
                "country": to_country_name("KE"),
                "contact_email": "partners@demo.local",
                "notes": rich_partner_notes,
            },
        )
        if partner.notes != rich_partner_notes:
            partner.partner_type = Partner.PartnerType.FUNDER
            partner.country = partner.country or to_country_name("KE")
            partner.contact_email = partner.contact_email or "partners@demo.local"
            partner.notes = rich_partner_notes
            partner.save(
                update_fields=["partner_type", "country", "contact_email", "notes"],
            )

        mou_title = "Demo MOU — RUFORUM Demo University × Demo Funder Consortium (IILMP seed)"
        mou, _ = MOU.objects.get_or_create(
            partner=partner,
            institution=inst,
            title=mou_title,
            defaults={
                "status": MOU.Status.ACTIVE,
                "signed_on": date.today() - timedelta(days=400),
                "expires_on": date.today() + timedelta(days=500),
                "created_by": gm,
            },
        )
        if mou.status != MOU.Status.ACTIVE or mou.signed_on is None:
            mou.status = MOU.Status.ACTIVE
            mou.signed_on = mou.signed_on or (date.today() - timedelta(days=400))
            mou.expires_on = mou.expires_on or (date.today() + timedelta(days=500))
            if mou.created_by_id is None:
                mou.created_by = gm
            mou.save(update_fields=["status", "signed_on", "expires_on", "created_by"])

        Member.objects.get_or_create(
            institution=inst,
            partner=partner,
            defaults={"notes": "Seeded member institution ↔ partner link"},
        )

        funding_source, _ = FundingSource.objects.get_or_create(
            code="demo-funder-consortium",
            defaults={
                "name": "Demo Funder Consortium (operating fund)",
                "description": "Seeded funding stream linked to operations partner.",
                "partner": partner,
            },
        )
        if funding_source.partner_id != partner.id:
            funding_source.partner = partner
            funding_source.save(update_fields=["partner"])

        return partner, mou, funding_source

    def _wire_grant_calls_ecosystem(self, partner, mou) -> None:
        """Link all seeded grant calls to the demo partner + MOU (PRD ecosystem connectivity)."""
        for slug in DEMO_GRANT_CALL_SLUGS:
            if slug == "reviewer-queue-e2e-call":
                continue
            gc = GrantCall.objects.filter(slug=slug).first()
            if not gc:
                continue
            updates = []
            if gc.partner_id != partner.id:
                gc.partner = partner
                updates.append("partner")
            if gc.mou_id != mou.id:
                gc.mou = mou
                updates.append("mou")
            if updates:
                updates.append("updated_at")
                gc.save(update_fields=updates)

    def _scope_demo_calls_to_institution(self, inst, *slugs: str) -> None:
        """Optional institution scope rows (empty = all institutions)."""
        for slug in slugs:
            gc = GrantCall.objects.filter(slug=slug).first()
            if not gc:
                continue
            if not gc.institutions_scope.filter(pk=inst.pk).exists():
                gc.institutions_scope.add(inst)

    def _assign_reviewer_when_under_review(self, application, reviewer, *, declare_conflict: bool = False):
        """Matches AssignReviewerPostView / services — only assign in UNDER_REVIEW (avoids seed crashes on re-run)."""
        # FSMField status cannot use refresh_from_db(fields=[...]); reload from DB.
        app = Application.objects.get(pk=application.pk)
        if app.status != Application.Status.UNDER_REVIEW:
            return
        assign_reviewer(app, reviewer, declare_conflict=declare_conflict)

    def _ensure_fm_budgets_for_awards_without_projects(self, funding_source: FundingSource) -> None:
        """Ensure every award without an execution Project has ≥1 FM Budget (project create form).

        Idempotent: leaves awards alone if they already have any Budget row.
        """
        qs = Award.objects.filter(project__isnull=True).select_related("application__call")
        for award in qs:
            if Budget.objects.filter(award=award).exists():
                continue
            call_slug = getattr(getattr(award.application, "call", None), "slug", None) or "grant"
            Budget.objects.create(
                award=award,
                name=f"Seeded FM budget ({call_slug})"[:255],
                fiscal_year=timezone.now().year,
                funding_source=funding_source,
                status=Budget.Status.APPROVED,
            )

    def _seed_disbursement_finance_state(
        self,
        *,
        request: DisbursementRequest,
        execution_status: str,
        reconciliation_status: str | None = None,
        reference: str = "",
        actor=None,
    ) -> None:
        execution, _ = DisbursementExecution.objects.get_or_create(
            request=request,
            defaults={
                "status": execution_status,
                "payment_method": request.payment_method,
                "scheduled_for": request.scheduled_for,
                "destination_account": request.account_reference,
                "execution_reference": reference,
            },
        )
        execution.status = execution_status
        execution.payment_method = request.payment_method
        execution.scheduled_for = request.scheduled_for
        execution.destination_account = request.account_reference
        if reference:
            execution.execution_reference = reference
        if execution_status == DisbursementExecution.Status.EXECUTED:
            execution.executed_at = execution.executed_at or timezone.now() - timedelta(days=5)
            request.status = DisbursementRequest.Status.PAID
        execution.save()
        request.save(update_fields=["status", "updated_at"])

        if reconciliation_status:
            reconciliation, _ = DisbursementReconciliation.objects.get_or_create(request=request)
            reconciliation.status = reconciliation_status
            reconciliation.notes = reconciliation.notes or "Seeded reconciliation state for finance workflow demos."
            reconciliation.reconciled_by = actor
            reconciliation.reconciled_at = reconciliation.reconciled_at or timezone.now() - timedelta(days=2)
            reconciliation.save()

    def _ensure_seed_funding_record(
        self,
        *,
        gm,
        fo,
        program_manager,
        partner,
    ) -> FundingRecord:
        """Seed one approved funding record and its balanced budget lines."""
        fr, _ = FundingRecord.objects.get_or_create(
            title="Seeded PRD funding record — flagship grants window",
            defaults={
                "partner": partner,
                "amount": Decimal("120000.00"),
                "currency": "USD",
                "start_date": timezone.now().date() - timedelta(days=60),
                "end_date": timezone.now().date() + timedelta(days=540),
                "strategic_objectives": _html_rich_description("Strategic objective alignment — seeded funding record"),
                "reporting_financial_frequency": FundingRecord.ReportingFrequency.QUARTERLY,
                "reporting_narrative_frequency": FundingRecord.ReportingFrequency.QUARTERLY,
                "grant_manager": gm,
                "finance_officer": fo,
                "program_lead": program_manager,
                "created_by": gm,
                "status": FundingRecord.Status.DRAFT,
            },
        )
        updates = []
        if fr.partner_id != partner.id:
            fr.partner = partner
            updates.append("partner")
        if fr.grant_manager_id != gm.id:
            fr.grant_manager = gm
            updates.append("grant_manager")
        if fr.finance_officer_id != fo.id:
            fr.finance_officer = fo
            updates.append("finance_officer")
        if fr.program_lead_id != program_manager.id:
            fr.program_lead = program_manager
            updates.append("program_lead")
        if fr.created_by_id is None:
            fr.created_by = gm
            updates.append("created_by")
        if updates:
            updates.append("updated_at")
            fr.save(update_fields=updates)

        if fr.budget_lines.count() < 3:
            fr.budget_lines.all().delete()
            GrantBudgetLine.objects.create(
                funding_record=fr,
                category="Research operations",
                description="Core programme personnel and implementation support",
                quantity=Decimal("1"),
                unit_cost=Decimal("60000.00"),
                sort_order=1,
            )
            GrantBudgetLine.objects.create(
                funding_record=fr,
                category="Travel and fieldwork",
                description="Partner engagement, site visits, and supervision",
                quantity=Decimal("1"),
                unit_cost=Decimal("30000.00"),
                sort_order=2,
            )
            GrantBudgetLine.objects.create(
                funding_record=fr,
                category="Equipment and materials",
                description="Research equipment and deployment materials",
                quantity=Decimal("1"),
                unit_cost=Decimal("30000.00"),
                sort_order=3,
            )

        fr.full_clean()
        if fr.status in (FundingRecord.Status.DRAFT, FundingRecord.Status.DRAFT_REVISION_REQUIRED):
            submit_funding_for_approval(fr)
            fr = FundingRecord.objects.get(pk=fr.pk)
        if fr.status == FundingRecord.Status.PENDING_APPROVAL:
            approve_funding_record(fr)
            fr = FundingRecord.objects.get(pk=fr.pk)
        return fr

    # ------------------------------------------------------------------
    # Detail-data seeders (idempotent, safe on re-run).
    # ------------------------------------------------------------------
    def _seed_expenditures_for_budget(self, budget: Budget, *, actor, today) -> None:
        """Seed a multi-month spread of expenditures on a budget so the cashflow
        chart shows variation and the expenditure list / filters are exercised.

        Idempotent: keyed by (budget_line, description) so re-running the seeder
        does not duplicate rows.
        """
        lines = list(budget.lines.order_by("pk"))
        if not lines:
            return
        plan: list[tuple[BudgetLine, Decimal, int, str, bool]] = []
        if len(lines) >= 2:
            l0, l1 = lines[0], lines[1]
            plan = [
                (l0, Decimal("420.00"), 95, "Inception workshop catering", False),
                (l0, Decimal("680.00"), 70, "Field assistant honoraria — month 1", False),
                (l0, Decimal("520.00"), 40, "Translator fees — community sessions", False),
                (l0, Decimal("310.00"), 14, "Office stationery and printing", True),
                (l1, Decimal("1180.00"), 80, "Vehicle hire — northern region trip", False),
                (l1, Decimal("260.00"), 50, "Per-diems — fieldwork days 5-7", False),
                (l1, Decimal("145.00"), 22, "Inter-city bus — supervision visit", True),
            ]
        else:
            l0 = lines[0]
            plan = [
                (l0, Decimal("420.00"), 95, "Inception workshop catering", False),
                (l0, Decimal("680.00"), 70, "Field assistant honoraria — month 1", False),
                (l0, Decimal("520.00"), 40, "Translator fees — community sessions", False),
                (l0, Decimal("310.00"), 14, "Office stationery and printing", True),
            ]
        for line, amount, days_ago, description, with_receipt in plan:
            if Expenditure.objects.filter(
                budget_line=line, description=description
            ).exists():
                continue
            receipt_file = None
            if with_receipt:
                receipt_file = ContentFile(
                    b"%PDF-1.4\nseed receipt\n%%EOF",
                    name=f"receipt-{line.pk}-{days_ago}.pdf",
                )
            Expenditure.objects.create(
                budget_line=line,
                amount=amount,
                description=description,
                incurred_on=today - timedelta(days=days_ago),
                receipt=receipt_file,
                created_by=actor,
            )

    def _seed_stipends_for_scholar(self, scholar: Scholar) -> None:
        """Seed a 4-row stipend history (monthly cadence) so the stipend list view
        and the my_scholarship_detail timeline have data."""
        today = timezone.now().date()
        plan = [
            (Decimal("750.00"), 120, "SEED-STP-001"),
            (Decimal("750.00"), 90, "SEED-STP-002"),
            (Decimal("750.00"), 60, "SEED-STP-003"),
            (Decimal("750.00"), 30, "SEED-STP-004"),
        ]
        for amount, days_ago, ref in plan:
            if scholar.stipends.filter(reference=ref).exists():
                continue
            Stipend.objects.create(
                scholar=scholar,
                amount=amount,
                currency="USD",
                paid_on=today - timedelta(days=days_ago),
                reference=ref,
            )

    def _seed_progress_reports_for_scholar(self, scholar: Scholar) -> None:
        """Seed two ProgressReport rows so the my_scholarship_detail page and
        scholar admin view show submitted reports. Idempotent on (scholar,
        period_label)."""
        plan = [
            ("2026-Q1", b"%PDF-1.4 seed Q1 report\n%%EOF", "Quarter 1 narrative — milestones M1/M2 on track."),
            ("2026-Q2", b"%PDF-1.4 seed Q2 report\n%%EOF", "Quarter 2 narrative — fieldwork concluded; analysis underway."),
        ]
        for period, body, notes in plan:
            if scholar.progress_reports.filter(period_label=period).exists():
                continue
            ProgressReport.objects.create(
                scholar=scholar,
                period_label=period,
                file=ContentFile(body, name=f"progress-{scholar.pk}-{period}.pdf"),
                notes=notes,
            )

    def _maybe_seed_graduation(self, scholar: Scholar) -> None:
        """No-op for active demo scholars; placeholder for future cohort that
        wants a Graduated badge demoed."""
        # Intentionally left as a no-op — keeping the demo scholar 'Active' lets
        # the alumni handoff signal still be seedable without conflict.
        return

    def _seed_strategic_objectives(self) -> None:
        plan = [
            ("Climate-resilient agriculture", "climate-resilient-agriculture", "Build adaptive capacity for smallholder farming systems."),
            ("Women in research leadership", "women-in-research-leadership", "Strengthen the pipeline of women into senior research roles."),
            ("Network capacity building", "network-capacity-building", "Improve member-institution research and management capacity."),
            ("Postgraduate training", "postgraduate-training", "Expand high-quality MSc and PhD opportunities across the network."),
            ("Policy engagement", "policy-engagement", "Translate research evidence into national and regional policy."),
        ]
        for name, slug, description in plan:
            StrategicObjective.objects.update_or_create(
                slug=slug,
                defaults={
                    "name": name,
                    "description": description,
                    "active": True,
                },
            )

    def _seed_donors(self, *, partner: Partner, actor) -> None:
        plan = [
            ("Demo Bilateral Aid Agency", Donor.DonorType.BILATERAL, "SE", "donors@bilateral.demo", None),
            ("Demo Multilateral Programme", Donor.DonorType.MULTILATERAL, "CH", "ops@multilateral.demo", None),
            ("Demo Foundation Trust", Donor.DonorType.FOUNDATION, "US", "grants@foundation.demo", partner),
        ]
        for name, donor_type, country, email, link_partner in plan:
            Donor.objects.update_or_create(
                name=name,
                country=to_country_name(country),
                defaults={
                    "donor_type": donor_type,
                    "contact_email": email,
                    "partner": link_partner,
                    "notes": f"Seeded demo donor — {name}",
                    "created_by": actor,
                },
            )

    def _seed_exchange_rates(self) -> None:
        """Seed common pairs used in the IILMP context. Idempotent on
        (from, to, effective_date)."""
        today = timezone.now().date()
        plan = [
            ("USD", "UGX", Decimal("3750.00000000"), today - timedelta(days=30)),
            ("USD", "UGX", Decimal("3825.00000000"), today),
            ("USD", "KES", Decimal("129.50000000"), today - timedelta(days=30)),
            ("USD", "KES", Decimal("131.20000000"), today),
            ("EUR", "USD", Decimal("1.08000000"), today),
        ]
        for src, dst, rate, eff in plan:
            ExchangeRate.objects.update_or_create(
                from_currency=src,
                to_currency=dst,
                effective_date=eff,
                defaults={"rate": rate},
            )

    def _seed_reviewer_profiles(self, *reviewers) -> None:
        """Add a ReviewerProfile for each seeded reviewer so the auto-assignment
        service has workload + expertise signals to balance against."""
        plan = {
            "reviewer@demo.local": (5, ["climate", "agriculture", "phd-supervision"]),
            "reviewer2@demo.local": (4, ["gender", "policy", "msc-supervision"]),
        }
        for reviewer in reviewers:
            if reviewer is None:
                continue
            cap, tags = plan.get(reviewer.email, (5, ["general"]))
            ReviewerProfile.objects.update_or_create(
                user=reviewer,
                defaults={
                    "available": True,
                    "max_concurrent_reviews": cap,
                    "expertise_tags": tags,
                    "notes": f"Seeded reviewer profile for {reviewer.email}.",
                },
            )

    # ------------------------------------------------------------------
    # Operations breadth: more partners / MOUs / members + singletons.
    # ------------------------------------------------------------------
    def _seed_more_partners_and_mous(self, inst, gm) -> None:
        """Add a handful of partners across types so the partner_list grid and
        MOU register feel populated. Idempotent on partner.name."""
        plan = [
            ("Lake Region Research Institute (seed)", Partner.PartnerType.RESEARCH, "KE", "lakeregion@demo.local"),
            ("Eastern Africa Smallholder Network (seed)", Partner.PartnerType.NGO, "TZ", "easan@demo.local"),
            ("Ministry of Higher Education (seed)", Partner.PartnerType.GOVERNMENT, "UG", "mohe@demo.local"),
            ("Pan-African Climate Foundation (seed)", Partner.PartnerType.FUNDER, "ZA", "paf@demo.local"),
            ("Greenfields Agritech Co. (seed)", Partner.PartnerType.PRIVATE, "RW", "agritech@demo.local"),
        ]
        for name, ptype, country, email in plan:
            partner, _ = Partner.objects.get_or_create(
                name=name,
                defaults={
                    "partner_type": ptype,
                    "country": to_country_name(country),
                    "contact_email": email,
                    "notes": _html_rich_description(f"Partner notes — {name}"),
                },
            )
            Member.objects.get_or_create(institution=inst, partner=partner)
            if not partner.mous.exists():
                # Mix of states: some active, some draft, one expired.
                if ptype in (Partner.PartnerType.FUNDER, Partner.PartnerType.NGO):
                    status = MOU.Status.ACTIVE
                    signed_offset, expires_offset = -300, 400
                elif ptype == Partner.PartnerType.GOVERNMENT:
                    status = MOU.Status.EXPIRED
                    signed_offset, expires_offset = -800, -30
                else:
                    status = MOU.Status.DRAFT
                    signed_offset, expires_offset = None, None
                MOU.objects.create(
                    partner=partner,
                    institution=inst,
                    title=f"Demo MOU — {inst.name} × {name}",
                    status=status,
                    signed_on=date.today() + timedelta(days=signed_offset) if signed_offset else None,
                    expires_on=date.today() + timedelta(days=expires_offset) if expires_offset else None,
                    created_by=gm,
                )

    def _seed_operations_singletons(self, gm) -> None:
        """ReminderConfig + VarianceAlertConfig singletons + RetentionPolicy
        rows. Each Celery alert / archive task expects these to exist."""
        if not ReminderConfig.objects.exists():
            ReminderConfig.objects.create(
                milestone_warn_days_before=14,
                agreement_chase_grace_days=14,
                agreement_chase_max_reminders=3,
                closeout_warn_days_before=7,
                award_letter_grace_days=7,
                updated_by=gm,
            )
        if not VarianceAlertConfig.objects.exists():
            VarianceAlertConfig.objects.create(
                enabled=True,
                percent_threshold=Decimal("20"),
                period_window_days=30,
                updated_by=gm,
            )
        retention_plan = [
            (RetentionPolicy.FundingType.GRANT, 7, 30),
            (RetentionPolicy.FundingType.SCHOLARSHIP, 5, 60),
            (RetentionPolicy.FundingType.FELLOWSHIP, 5, 60),
            (RetentionPolicy.FundingType.CHALLENGE, 3, 30),
        ]
        for funding_type, years, archive_days in retention_plan:
            RetentionPolicy.objects.update_or_create(
                funding_type=funding_type,
                defaults={
                    "retention_years": years,
                    "archive_after_days": archive_days,
                    "description": f"Default retention policy for {funding_type} archives.",
                    "updated_by": gm,
                },
            )

    # ------------------------------------------------------------------
    # Grants lifecycle artifacts.
    # ------------------------------------------------------------------
    def _seed_application_documents(self, application: Application) -> None:
        """Attach a CV + proposal + reference letter so the application detail
        page shows real document chips. Idempotent on (application, label)."""
        plan = [
            ("CV / Résumé", b"%PDF-1.4 seed CV\n%%EOF", False),
            ("Research proposal", b"%PDF-1.4 seed proposal\n%%EOF", False),
            ("Reference letter", b"%PDF-1.4 seed reference\n%%EOF", False),
        ]
        for label, body, is_budget in plan:
            if application.documents.filter(label=label).exists():
                continue
            ApplicationDocument.objects.create(
                application=application,
                label=label,
                file=ContentFile(body, name=f"app-{application.pk}-{label.lower().replace(' ', '-').replace('/', '-')}.pdf"),
                is_budget=is_budget,
            )

    def _seed_interview_outcome(self, interview: InterviewSchedule, *, actor) -> None:
        """Record an attended outcome on the seeded interview so the panel UI
        shows a complete history."""
        if hasattr(interview, "outcome"):
            return
        InterviewOutcome.objects.create(
            interview=interview,
            attendance=InterviewOutcome.Attendance.ATTENDED,
            score=Decimal("82"),
            recommendation_code=Review.RecommendationCode.APPROVE,
            summary="Seeded interview outcome — applicant attended, recommendation forwarded for award.",
            recorded_by=actor,
        )

    def _seed_award_agreement_and_tranches(self, award: Award, *, gm, recip) -> None:
        """Add a signed AwardAgreement v1 plus 2 tranches that sum to amount."""
        if not award.agreements.exists():
            AwardAgreement.objects.create(
                award=award,
                version_number=1,
                title=f"Award agreement v1 — {award.application.call.title}",
                document=ContentFile(b"%PDF-1.4 seed agreement\n%%EOF", name=f"agreement-{award.pk}.pdf"),
                accepted=True,
                accepted_at=timezone.now() - timedelta(days=30),
                accepted_by=recip,
                uploaded_by=gm,
            )
        if award.tranches.exists():
            return
        # Award amount split 60/40 across two tranches; second linked to accepted milestone gate.
        amt1 = (award.amount * Decimal("0.6")).quantize(Decimal("0.01"))
        amt2 = award.amount - amt1
        accepted_gate = (
            Milestone.objects.filter(project__award=award, status=Milestone.Status.ACCEPTED)
            .order_by("due_date")
            .first()
        )
        AwardTranche.objects.create(
            award=award,
            label="Tranche 1 — inception",
            amount=amt1,
            due_on=date.today() - timedelta(days=60),
            status=AwardTranche.Status.DISBURSED,
            payment_reference="TRX-SEED-T1",
            disbursed_at=timezone.now() - timedelta(days=55),
            sort_order=1,
        )
        AwardTranche.objects.create(
            award=award,
            label="Tranche 2 — milestone-gated",
            amount=amt2,
            due_on=date.today() + timedelta(days=30),
            status=AwardTranche.Status.PLANNED,
            milestone=accepted_gate,
            sort_order=2,
        )

    def _seed_milestone_evidence(self, award: Award, *, recip) -> None:
        """For the milestone-gated tranche, attach an evidence file."""
        gated_tranche = award.tranches.filter(milestone__isnull=False).first()
        if not gated_tranche or gated_tranche.evidence.exists():
            return
        MilestoneEvidence.objects.create(
            tranche=gated_tranche,
            file=ContentFile(b"%PDF-1.4 seed evidence\n%%EOF", name=f"evidence-{gated_tranche.pk}.pdf"),
            summary="Seeded milestone evidence — field reports + photographs supporting tranche release.",
            submitted_by=recip,
            review_status=MilestoneEvidence.ReviewStatus.SUBMITTED,
        )

    def _seed_academic_progress_reports(self, award: Award, *, recip) -> None:
        """Two academic-cycle reports on the award (semester cadence)."""
        plan = [
            ("Sem 1 2026", Decimal("3.65"), "Dr. A. Mukasa", AcademicProgressReport.Status.ACCEPTED),
            ("Sem 2 2026", Decimal("3.70"), "Dr. A. Mukasa", AcademicProgressReport.Status.SUBMITTED),
        ]
        for period, gpa, supervisor, status in plan:
            if award.academic_progress_reports.filter(period_label=period).exists():
                continue
            AcademicProgressReport.objects.create(
                award=award,
                period_label=period,
                gpa=gpa,
                supervisor_name=supervisor,
                supervisor_signoff_at=timezone.now() - timedelta(days=14),
                summary=f"Seeded academic progress report for {period}.",
                transcript=ContentFile(
                    b"%PDF-1.4 seed transcript\n%%EOF",
                    name=f"transcript-{award.pk}-{period.replace(' ', '_')}.pdf",
                ),
                status=status,
                submitted_by=recip,
            )

    def _seed_reporting_calendar(self, funding_record: FundingRecord) -> None:
        """Generate quarterly financial + narrative reporting checkpoints for
        the seeded funding record."""
        if funding_record.reporting_calendar.exists():
            return
        start = funding_record.start_date or (timezone.now().date() - timedelta(days=60))
        for q in range(4):
            due = start + timedelta(days=90 * (q + 1))
            ReportingCalendarEntry.objects.update_or_create(
                funding_record=funding_record,
                kind=ReportingCalendarEntry.Kind.FINANCIAL,
                due_on=due,
                defaults={"label": f"Q{q + 1} financial report"},
            )
            ReportingCalendarEntry.objects.update_or_create(
                funding_record=funding_record,
                kind=ReportingCalendarEntry.Kind.NARRATIVE,
                due_on=due,
                defaults={"label": f"Q{q + 1} narrative report"},
            )

    # ------------------------------------------------------------------
    # Projects breadth: deliverables + beneficiaries + a 2nd Project.
    # ------------------------------------------------------------------
    def _seed_deliverables_for_project(self, project: Project, *, actor) -> None:
        """Attach 1-2 deliverables to the accepted milestone so the project
        detail's deliverable badges aren't empty."""
        accepted = project.milestones.filter(status=Milestone.Status.ACCEPTED).first()
        if not accepted or accepted.deliverables.exists():
            return
        Deliverable.objects.create(
            milestone=accepted,
            title="Inception report",
            file=ContentFile(b"%PDF-1.4 seed inception\n%%EOF", name=f"inception-{accepted.pk}.pdf"),
            notes="Seeded inception report covering programme scope and methodology.",
        )
        Deliverable.objects.create(
            milestone=accepted,
            title="Field photos bundle",
            file=ContentFile(b"%PDF-1.4 seed photos\n%%EOF", name=f"photos-{accepted.pk}.pdf"),
            notes="Photographic evidence of community workshops.",
        )

    def _seed_beneficiaries_for_project(self, project: Project, *, actor) -> None:
        """6 Beneficiary rows + 4 attendance entries spanning two events."""
        if project.beneficiaries.exists():
            return
        plan = [
            ("Mary Nansubuga", Beneficiary.BeneficiaryRole.DIRECT, {"gender": "F", "age": 28, "district": "Mukono"}),
            ("Joseph Olara", Beneficiary.BeneficiaryRole.DIRECT, {"gender": "M", "age": 34, "district": "Lira"}),
            ("Aisha Wanjala", Beneficiary.BeneficiaryRole.DIRECT, {"gender": "F", "age": 41, "district": "Bungoma"}),
            ("Daniel Mubiru", Beneficiary.BeneficiaryRole.INDIRECT, {"gender": "M", "age": 52, "district": "Wakiso"}),
            ("Esther Kebede", Beneficiary.BeneficiaryRole.STAKEHOLDER, {"gender": "F", "age": 47, "role": "Extension officer"}),
            ("Patrick Musoke", Beneficiary.BeneficiaryRole.PARTNER, {"gender": "M", "age": 39, "org": "District Agricultural Office"}),
        ]
        beneficiaries = []
        for full_name, role, demographic in plan:
            beneficiary = Beneficiary.objects.create(
                project=project,
                full_name=full_name,
                role=role,
                contact_email=f"{full_name.lower().replace(' ', '.')}@demo.local",
                demographic=demographic,
                joined_on=date.today() - timedelta(days=90),
                created_by=actor,
                notes="Seeded beneficiary record.",
            )
            beneficiaries.append(beneficiary)
        # Two demo workshops; first 3 beneficiaries attend both, last 3 attend one.
        events = [
            ("Inception community workshop", date.today() - timedelta(days=80)),
            ("Mid-term review meeting", date.today() - timedelta(days=20)),
        ]
        for ev_label, ev_date in events:
            for beneficiary in beneficiaries[:3]:
                BeneficiaryAttendance.objects.create(
                    beneficiary=beneficiary,
                    event_label=ev_label,
                    occurred_on=ev_date,
                    notes="Confirmed attendance.",
                    recorded_by=actor,
                )

    def _seed_secondary_project(self, *, gm, today) -> Project | None:
        """Build a Project on the already-awarded pilot-call application (by
        mel) so the project list isn't a single row. The existing seeder
        deliberately leaves that award project-less for the project-create
        form demo; we promote it once the rest of the seed is in place."""
        award = (
            Award.objects.filter(application__call__slug="e2e-pilot-call")
            .select_related("application__applicant")
            .first()
        )
        if award is None:
            return None
        if hasattr(award, "project"):
            return award.project
        applicant_user = award.application.applicant
        project = Project.objects.create(
            award=award,
            title="Seeded secondary research project",
            lead=applicant_user,
            summary=_html_rich_description("Project summary — seeded secondary project"),
            start_date=today - timedelta(days=30),
            end_date=award.project_end_date,
            status=Project.Status.ACTIVE,
        )
        Milestone.objects.create(
            project=project,
            name="Inception report",
            due_date=today + timedelta(days=30),
            report_due=True,
            status=Milestone.Status.PLANNED,
        )
        Milestone.objects.create(
            project=project,
            name="Mid-term review",
            due_date=today + timedelta(days=120),
            report_due=True,
            status=Milestone.Status.PLANNED,
        )
        ProjectBudgetLine.objects.create(
            project=project,
            label="Operations",
            allocated=Decimal("3000.00"),
            spent=Decimal("450.00"),
        )
        ProjectBudgetLine.objects.create(
            project=project,
            label="Travel",
            allocated=Decimal("1500.00"),
            spent=Decimal("0.00"),
        )
        return project

    # ------------------------------------------------------------------
    # Scholarships breadth.
    # ------------------------------------------------------------------
    def _seed_more_scholarships_and_scholars(self, *, inst, partner, password, demo_call) -> None:
        """Add a 2nd + 3rd Scholarship programme and seed extra scholars across
        cohorts so list views feel populated. Idempotent on programme name."""
        programme_plan = [
            ("Climate-resilient PhD fellowship", "Fellowship for PhD candidates in climate-smart research."),
            ("Women in agritech MSc programme", "MSc programme strengthening women researchers in agritech."),
        ]
        for prog_name, prog_desc in programme_plan:
            Scholarship.objects.update_or_create(
                name=prog_name,
                defaults={
                    "description": _html_rich_description(prog_desc),
                    "grant_call": demo_call,
                    "partner": partner,
                    "is_active": True,
                },
            )
        scholar_plan = [
            ("scholar2@demo.local", "Halima", "Mwangi", "Climate-resilient PhD fellowship", 2024, False),
            ("scholar3@demo.local", "Tendai", "Moyo", "Climate-resilient PhD fellowship", 2025, False),
            ("scholar4@demo.local", "Naomi", "Owino", "Women in agritech MSc programme", 2023, True),
            ("scholar5@demo.local", "Esi", "Asante", "Women in agritech MSc programme", 2024, False),
        ]
        for email, first, last, prog_name, cohort, graduated in scholar_plan:
            user, _ = User.objects.get_or_create(
                email=email,
                defaults={"role": UserRole.SCHOLAR, "institution": inst},
            )
            user.set_password(password)
            user.first_name = first
            user.last_name = last
            user.role = UserRole.SCHOLAR
            user.institution = inst
            user.save()
            programme = Scholarship.objects.filter(name=prog_name).first()
            if not programme:
                continue
            scholar_row, _ = Scholar.objects.get_or_create(
                user=user,
                scholarship=programme,
                defaults={
                    "institution": inst,
                    "cohort_year": cohort,
                    "notes": f"Seeded scholar — {prog_name} ({cohort}).",
                },
            )
            self._seed_stipends_for_scholar(scholar_row)
            if graduated:
                GraduationRecord.objects.update_or_create(
                    scholar=scholar_row,
                    defaults={
                        "graduated_on": date.today() - timedelta(days=120),
                        "certificate_issued": True,
                        "notes": "Seeded graduation record — completed programme on schedule.",
                    },
                )

    # ------------------------------------------------------------------
    # Finance: BudgetRevision flow.
    # ------------------------------------------------------------------
    def _seed_budget_revision(self, budget: Budget, *, gm) -> None:
        """One REQUESTED revision with line deltas so the revision UI demo has
        content to render."""
        if budget.revisions.exists():
            return
        lines = list(budget.lines.order_by("pk"))
        if len(lines) < 2:
            return
        revision = BudgetRevision.objects.create(
            budget=budget,
            version=1,
            status=BudgetRevision.Status.REQUESTED,
            change_summary=(
                "Seeded revision — reallocate from operations to personnel after revised "
                "field-cost estimates."
            ),
            requested_by=gm,
        )
        l0, l1 = lines[0], lines[1]
        BudgetRevisionLine.objects.create(
            revision=revision,
            line=l0,
            old_amount=l0.amount,
            new_amount=l0.amount + Decimal("2000.00"),
            justification="Increase personnel allocation to cover an additional field assistant.",
        )
        BudgetRevisionLine.objects.create(
            revision=revision,
            line=l1,
            old_amount=l1.amount,
            new_amount=max(Decimal("0.00"), l1.amount - Decimal("2000.00")),
            justification="Offset reduction from operations contingency.",
        )

    # ------------------------------------------------------------------
    # Phase 1 — SRS §5.1/§5.4 named-persona fixture wiring.
    # ------------------------------------------------------------------
    def _seed_phase1_persona_fixtures(
        self,
        *,
        inst,
        gm,
        scholar,
        seed_budget: Budget | None,
        project_manager_user,
    ) -> None:
        """Give the four Phase 1 personas at least one live RIMS row each.

        - finance.manager / program.coordinator already have a queue via the
          existing SUBMITTED disbursement and REQUESTED budget revision rows
          (FinanceApprovalMixin / ProgramCoordinatorMixin gate by role only).
        - project.manager needs a DisbursementRequest where they are the raiser.
        - programme.officer needs at least one Application sitting at SUBMITTED
          on a call with `programme_officer_screening_enabled=True`.
        """
        # PO screening queue — dedicated call so existing e2e fixtures stay clean.
        po_call, created = GrantCall.objects.get_or_create(
            slug="e2e-po-screening-call",
            defaults={
                "title": "E2E programme officer screening call",
                "description": "Seeded call requiring PO screening before review queue advance.",
                "guidelines": "Dev only — PO screening fixture.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=90),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "programme_officer_screening_enabled": True,
                "reviewers_per_application": 1,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        if not po_call.programme_officer_screening_enabled:
            po_call.programme_officer_screening_enabled = True
            po_call.save(update_fields=["programme_officer_screening_enabled", "updated_at"])
        po_call.reviewer_pool.add(gm)
        if po_call.status == GrantCall.Status.DRAFT:
            self._ensure_publish_prerequisites(po_call)
            publish_call(po_call, auto_close_acknowledged=True)
        self._ensure_required_documents(po_call, DOCS_STANDARD_LIGHT)
        self._sync_grant_call_html(po_call)

        po_app, _ = Application.objects.get_or_create(
            call=po_call,
            applicant=scholar,
            defaults={"institution": inst},
        )
        self._sync_application_proposal(
            po_app,
            "Research proposal — PO screening queue fixture",
        )
        # FSMField cannot be set via refresh_from_db; reload from DB instead.
        po_app = Application.objects.get(pk=po_app.pk)
        if po_app.status == Application.Status.DRAFT:
            submit_application(po_app)
        # programme_officer_screening_enabled=True keeps eligible apps at SUBMITTED.

        # project.manager raiser — additional pending disbursement so PM has a
        # tile on the disbursement list page from first login.
        if seed_budget is not None:
            already = seed_budget.disbursement_requests.filter(
                requested_by=project_manager_user,
                status=DisbursementRequest.Status.SUBMITTED,
            ).exists()
            if not already:
                DisbursementRequest.objects.create(
                    budget=seed_budget,
                    amount=Decimal("1200.00"),
                    justification=_html_rich_description(
                        "Disbursement justification — PM-raised request (Phase 1 fixture)"
                    ),
                    status=DisbursementRequest.Status.SUBMITTED,
                    payment_method=DisbursementRequest.PaymentMethod.BANK_TRANSFER,
                    scheduled_for=timezone.now().date() + timedelta(days=21),
                    account_reference="BEN-OPS-PM-001",
                    requested_by=project_manager_user,
                )

    def _seed_comprehensive(self, inst, gm, rev, scholar, password):
        # Review-queue scholar must pass screening on every seed (blank degree would mark ineligible).
        self._set_profile_screening(scholar, degree=UserProfile.DegreeLevel.MASTERS)

        fo, _ = User.objects.get_or_create(
            email="finance@demo.local",
            defaults={"role": UserRole.FINANCE_OFFICER, "institution": inst},
        )
        fo.set_password(password)
        fo.role = UserRole.FINANCE_OFFICER
        fo.save()

        self._ensure_demo_user("system.admin@demo.local", UserRole.SYSTEM_ADMIN, inst, password)
        program_manager = self._ensure_demo_user(
            "program.manager@demo.local", UserRole.PROGRAM_MANAGER, inst, password
        )
        program_director = self._ensure_demo_user(
            "program.director@demo.local", UserRole.PROGRAM_DIRECTOR, inst, password
        )
        finance_director = self._ensure_demo_user(
            "finance.director@demo.local", UserRole.FINANCE_DIRECTOR, inst, password
        )

        # Phase 1 — SRS §5.1/§5.4 personas missing from the named-seed set.
        finance_manager = self._ensure_demo_user(
            "finance.manager@demo.local", UserRole.FINANCE_MANAGER, inst, password
        )
        program_coordinator = self._ensure_demo_user(
            "program.coordinator@demo.local", UserRole.PROGRAM_COORDINATOR, inst, password
        )
        project_manager_user = self._ensure_demo_user(
            "project.manager@demo.local", UserRole.PROJECT_MANAGER, inst, password
        )
        programme_officer = self._ensure_demo_user(
            "programme.officer@demo.local", UserRole.PROGRAMME_OFFICER, inst, password
        )

        self._ensure_demo_user("mel@demo.local", UserRole.MEL_OFFICER, inst, password)
        rev2 = self._ensure_demo_user("reviewer2@demo.local", UserRole.REVIEWER, inst, password)

        demo_call = GrantCall.objects.get(slug="demo-seed-call")
        partner, mou, funding_source = self._ensure_demo_ecosystem(inst, gm)
        seeded_funding = self._ensure_seed_funding_record(
            gm=gm,
            fo=fo,
            program_manager=program_manager,
            partner=partner,
        )

        # Second published call (HTMX / multi-call list)
        pilot, _ = GrantCall.objects.get_or_create(
            slug="e2e-pilot-call",
            defaults={
                "title": "Pilot cohort 2026 — resilience fund",
                "description": "Second open call for UI filtering tests.",
                "guidelines": "Dev only.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=90),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "reviewers_per_application": 1,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        pilot.reviewer_pool.add(gm, rev, rev2)
        if pilot.status == GrantCall.Status.DRAFT:
            self._ensure_publish_prerequisites(pilot)
            publish_call(pilot, auto_close_acknowledged=True)
        self._ensure_required_documents(pilot, DOCS_STANDARD_LIGHT)
        self._sync_grant_call_html(pilot)

        # Pilot: one awarded application with no Project — populates "New project" + finance budget dropdown.
        mel = User.objects.get(email="mel@demo.local")
        self._set_profile_screening(mel, degree=UserProfile.DegreeLevel.PHD)
        app_pilot, _ = Application.objects.get_or_create(
            call=pilot,
            applicant=mel,
            defaults={"institution": inst},
        )
        self._sync_application_proposal(
            app_pilot,
            "Research proposal — pilot cohort (MEL officer, new project form E2E)",
        )
        app_pilot = Application.objects.get(pk=app_pilot.pk)
        if app_pilot.status == Application.Status.DRAFT:
            submit_application(app_pilot)
        app_pilot = Application.objects.get(pk=app_pilot.pk)
        if app_pilot.status == Application.Status.UNDER_REVIEW:
            shortlist_application(app_pilot)
        app_pilot = Application.objects.get(pk=app_pilot.pk)
        if app_pilot.status == Application.Status.SHORTLISTED:
            award_application(
                app_pilot,
                Decimal("8500.00"),
                date.today() + timedelta(days=400),
                narrative=_html_rich_description("Pilot cohort — award pending execution project"),
            )

        # Call dedicated to reviewer queue (scholar application + assignment)
        reviewer_queue_title = "E2E reviewer queue (scholar pending)"
        rev_call, _ = GrantCall.objects.get_or_create(
            slug="e2e-reviewer-call",
            defaults={
                "title": reviewer_queue_title,
                "description": "Has a pending review for reviewer@demo.local.",
                "guidelines": "Dev.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=45),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "reviewers_per_application": 1,
                "review_score_threshold": Decimal("65.00"),
                "review_deadline_days": 10,
                "scoring_rubric": [
                    {"criterion": "Scientific merit", "max_points": 40},
                    {"criterion": "Impact", "max_points": 30},
                    {"criterion": "Feasibility", "max_points": 30},
                ],
            },
        )
        rev_call.reviewer_pool.add(rev, rev2, gm)
        if rev_call.title != reviewer_queue_title:
            rev_call.title = reviewer_queue_title
            rev_call.save(update_fields=["title", "updated_at"])
        if rev_call.status == GrantCall.Status.DRAFT:
            self._ensure_publish_prerequisites(rev_call)
            publish_call(rev_call, auto_close_acknowledged=True)
        self._ensure_required_documents(rev_call, DOCS_STANDARD)
        self._sync_grant_call_html(rev_call)

        funding_allocations = {
            "demo-seed-call": Decimal("35000.00"),
            "e2e-pilot-call": Decimal("25000.00"),
            "e2e-reviewer-call": Decimal("12000.00"),
            "e2e-eligibility-call": Decimal("8000.00"),
            "e2e-interview-call": Decimal("15000.00"),
            "e2e-conflict-call": Decimal("12000.00"),
            "e2e-blind-call": Decimal("13000.00"),
        }
        for slug, ceiling in funding_allocations.items():
            gc = GrantCall.objects.filter(slug=slug).first()
            if not gc:
                continue
            gc_updates = []
            if gc.funding_record_id != seeded_funding.id:
                gc.funding_record = seeded_funding
                gc_updates.append("funding_record")
            if gc.budget_ceiling != ceiling:
                gc.budget_ceiling = ceiling
                gc_updates.append("budget_ceiling")
            if gc_updates:
                gc_updates.append("updated_at")
                gc.save(update_fields=gc_updates)

        app_r, _ = Application.objects.get_or_create(
            call=rev_call,
            applicant=scholar,
            defaults={"institution": inst},
        )
        if app_r.status == Application.Status.DRAFT:
            submit_application(app_r)
        app_r = Application.objects.get(pk=app_r.pk)
        self._assign_reviewer_when_under_review(app_r, rev, declare_conflict=False)
        self._sync_application_proposal(app_r, "Research proposal — E2E reviewer queue (scholar)")

        # Award + budget + submitted disbursement (finance approval UI)
        recip, _ = User.objects.get_or_create(
            email="award.recipient@demo.local",
            defaults={"role": UserRole.SCHOLAR, "institution": inst},
        )
        recip.set_password(password)
        recip.role = UserRole.SCHOLAR
        recip.institution = inst
        recip.save()
        self._set_profile_screening(recip, degree=UserProfile.DegreeLevel.PHD)

        app_aw, _ = Application.objects.get_or_create(
            call=demo_call,
            applicant=recip,
            defaults={"institution": inst},
        )
        if app_aw.status == Application.Status.DRAFT:
            submit_application(app_aw)
        app_aw = Application.objects.get(pk=app_aw.pk)
        self._sync_application_proposal(
            app_aw,
            "Full proposal — demo seed award recipient",
        )
        app_aw = Application.objects.get(pk=app_aw.pk)
        # Two submitted reviews + rubric scores before shortlist/award (manager list avg / review counts).
        if app_aw.status == Application.Status.UNDER_REVIEW:
            self._assign_reviewer_when_under_review(app_aw, rev, declare_conflict=False)
            self._assign_reviewer_when_under_review(app_aw, rev2, declare_conflict=False)
            r_aw_a = Review.objects.filter(application=app_aw, reviewer=rev).first()
            r_aw_b = Review.objects.filter(application=app_aw, reviewer=rev2).first()
            demo_award_scores_a = {
                "Scientific merit": Decimal("35"),
                "Impact": Decimal("28"),
                "Feasibility": Decimal("22"),
            }
            demo_award_scores_b = {
                "Scientific merit": Decimal("33"),
                "Impact": Decimal("26"),
                "Feasibility": Decimal("20"),
            }
            if r_aw_a and r_aw_a.submitted_at is None:
                submit_review(
                    r_aw_a,
                    demo_award_scores_a,
                    "Strong merit and impact; recommend funding within programme budget.",
                )
            if r_aw_b and r_aw_b.submitted_at is None:
                submit_review(
                    r_aw_b,
                    demo_award_scores_b,
                    "Meets bar for award; standard monitoring recommended.",
                )
            app_aw = Application.objects.get(pk=app_aw.pk)
            if app_aw.status == Application.Status.UNDER_REVIEW:
                shortlist_application(app_aw)
                app_aw = Application.objects.get(pk=app_aw.pk)
        app_aw = Application.objects.get(pk=app_aw.pk)
        if app_aw.status == Application.Status.SHORTLISTED:
            award_application(
                app_aw,
                Decimal("12000.00"),
                date.today() + timedelta(days=365),
                narrative=_html_rich_description("Award narrative — seeded demo recipient (finance E2E)"),
            )

        app_aw = Application.objects.get(pk=app_aw.pk)
        if hasattr(app_aw, "award"):
            award = app_aw.award
            today = timezone.now().date()
            rich_narrative = _html_rich_description("Award narrative — seeded demo recipient (finance E2E)")
            if award.narrative != rich_narrative:
                award.narrative = rich_narrative
                award.save(update_fields=["narrative"])
            budget, _ = Budget.objects.get_or_create(
                award=award,
                name="Seeded operating budget",
                defaults={
                    "fiscal_year": timezone.now().year,
                    "status": Budget.Status.APPROVED,
                    "account_reference": "OPS-001",
                },
            )
            budget_updates = []
            if budget.funding_source_id != funding_source.id:
                budget.funding_source = funding_source
                budget_updates.append("funding_source")
            if budget.status != Budget.Status.APPROVED:
                budget.status = Budget.Status.APPROVED
                budget_updates.append("status")
            if budget.account_reference != "OPS-001":
                budget.account_reference = "OPS-001"
                budget_updates.append("account_reference")
            if budget_updates:
                budget.save(update_fields=budget_updates)
            if not budget.lines.exists():
                BudgetLine.objects.create(
                    budget=budget,
                    label="Personnel",
                    category=BudgetLine.Category.PERSONNEL,
                    quantity=Decimal("12"),
                    unit="months",
                    unit_cost=Decimal("4166.67"),
                    account_code="OPS-PERS",
                    allocation_year=timezone.now().year,
                    amount=Decimal("50000.04"),
                )
                BudgetLine.objects.create(
                    budget=budget,
                    label="Supplies",
                    category=BudgetLine.Category.OPERATIONS,
                    quantity=Decimal("1"),
                    unit="lot",
                    unit_cost=Decimal("10000.00"),
                    account_code="OPS-SUP",
                    allocation_year=timezone.now().year,
                    amount=Decimal("10000.00"),
                )
            if not budget.disbursement_requests.filter(status=DisbursementRequest.Status.SUBMITTED).exists():
                DisbursementRequest.objects.create(
                    budget=budget,
                    amount=Decimal("2500.00"),
                    justification=_html_rich_description(
                        "Disbursement justification — seeded pending request (Quill / finance E2E)"
                    ),
                    status=DisbursementRequest.Status.SUBMITTED,
                    payment_method=DisbursementRequest.PaymentMethod.BANK_TRANSFER,
                    scheduled_for=today + timedelta(days=14),
                    account_reference="BEN-OPS-001",
                    requested_by=gm,
                )
            dr_sub = (
                budget.disbursement_requests.filter(status=DisbursementRequest.Status.SUBMITTED)
                .order_by("-pk")
                .first()
            )
            if dr_sub:
                rich_just = _html_rich_description(
                    "Disbursement justification — seeded pending request (Quill / finance E2E)"
                )
                if dr_sub.justification != rich_just:
                    dr_sub.justification = rich_just
                dr_sub.payment_method = DisbursementRequest.PaymentMethod.BANK_TRANSFER
                dr_sub.scheduled_for = dr_sub.scheduled_for or (today + timedelta(days=14))
                dr_sub.account_reference = dr_sub.account_reference or "BEN-OPS-001"
                dr_sub.save(update_fields=["justification", "payment_method", "scheduled_for", "account_reference"])

            overdue_cutoff = today - timedelta(days=7)
            project, _ = Project.objects.get_or_create(
                award=award,
                defaults={
                    "title": "Seeded PRD dashboard project",
                    "lead": recip,
                    "summary": _html_rich_description(
                        "Project summary — seeded PRD dashboard project (milestones E2E)"
                    ),
                    "start_date": today - timedelta(days=120),
                    "end_date": award.project_end_date,
                    "status": Project.Status.AT_RISK,
                },
            )
            rich_summary = _html_rich_description(
                "Project summary — seeded PRD dashboard project (milestones E2E)"
            )
            if project.summary != rich_summary:
                project.summary = rich_summary
                project.save(update_fields=["summary"])
            if project.finance_budget_id != budget.id:
                project.finance_budget = budget
                project.save(update_fields=["finance_budget"])
            if project.lead_id is None:
                project.lead = recip
                project.save(update_fields=["lead"])
            if project.milestones.count() < 3:
                Milestone.objects.filter(project=project).delete()
                m1 = Milestone.objects.create(
                    project=project,
                    name="3-month progress report",
                    due_date=overdue_cutoff - timedelta(days=7),
                    report_due=True,
                    status=Milestone.Status.OVERDUE,
                )
                m2 = Milestone.objects.create(
                    project=project,
                    name="6-month progress report",
                    due_date=today + timedelta(days=5),
                    report_due=True,
                    status=Milestone.Status.REVISION_REQUIRED,
                    report_submitted_at=timezone.now() - timedelta(days=1),
                    report_summary="Submitted with partial evidence; revisions requested by programme lead.",
                    submitted_by=program_manager,
                    reviewed_at=timezone.now(),
                    reviewed_by=program_director,
                    review_comment="Please provide complete field evidence before tranche release.",
                )
                m3 = Milestone.objects.create(
                    project=project,
                    name="9-month progress report",
                    due_date=today + timedelta(days=90),
                    report_due=True,
                    report_submitted_at=timezone.now(),
                    status=Milestone.Status.ACCEPTED,
                    report_summary="Full report submitted and accepted for disbursement gate.",
                    submitted_by=program_manager,
                    reviewed_at=timezone.now(),
                    reviewed_by=finance_director,
                    review_comment="Accepted — milestone gate passed for finance processing.",
                )
                MilestoneAudit.objects.create(
                    milestone=m1,
                    event="seed_overdue",
                    actor=program_manager,
                    payload={"status": m1.status, "source": "seed_rims"},
                )
                MilestoneAudit.objects.create(
                    milestone=m2,
                    event="seed_revision_required",
                    actor=program_director,
                    payload={"status": m2.status, "source": "seed_rims"},
                )
                MilestoneAudit.objects.create(
                    milestone=m3,
                    event="seed_accepted",
                    actor=finance_director,
                    payload={"status": m3.status, "source": "seed_rims"},
                )
            accepted_gate = project.milestones.filter(status=Milestone.Status.ACCEPTED).order_by("due_date").first()
            if accepted_gate and dr_sub and dr_sub.milestone_id != accepted_gate.id:
                dr_sub.milestone = accepted_gate
                dr_sub.save(update_fields=["milestone"])

            # Finance ↔ project connectivity: expenditures, tranches, second budget, execution lines, risk.
            line_main = budget.lines.order_by("pk").first()
            self._seed_expenditures_for_budget(budget, actor=fo, today=today)

            if not budget.payment_schedules.exists():
                PaymentSchedule.objects.create(
                    budget=budget,
                    due_on=today - timedelta(days=60),
                    amount=Decimal("15000.00"),
                    label="Q1 tranche (paid)",
                    paid=True,
                )
                PaymentSchedule.objects.create(
                    budget=budget,
                    due_on=today + timedelta(days=30),
                    amount=Decimal("20000.00"),
                    label="Q2 tranche (upcoming)",
                    paid=False,
                )

            cap_fs, _ = FundingSource.objects.get_or_create(
                code="demo-matching-funds",
                defaults={
                    "name": "Demo matching funds pool",
                    "partner": partner,
                    "description": "Secondary funding stream for multi-budget award demos.",
                },
            )
            if cap_fs.partner_id != partner.id:
                cap_fs.partner = partner
                cap_fs.save(update_fields=["partner"])

            cap_budget, _ = Budget.objects.get_or_create(
                award=award,
                name="Capital / equipment reserve",
                defaults={
                    "fiscal_year": timezone.now().year,
                    "funding_source": cap_fs,
                    "status": Budget.Status.FINALIZED,
                    "account_reference": "CAP-001",
                },
            )
            cap_updates = []
            if cap_budget.funding_source_id != cap_fs.id:
                cap_budget.funding_source = cap_fs
                cap_updates.append("funding_source")
            if cap_budget.status != Budget.Status.FINALIZED:
                cap_budget.status = Budget.Status.FINALIZED
                cap_updates.append("status")
            if cap_budget.account_reference != "CAP-001":
                cap_budget.account_reference = "CAP-001"
                cap_updates.append("account_reference")
            if cap_updates:
                cap_budget.save(update_fields=cap_updates)
            if not cap_budget.lines.exists():
                BudgetLine.objects.create(
                    budget=cap_budget,
                    label="Equipment",
                    category=BudgetLine.Category.EQUIPMENT,
                    quantity=Decimal("5"),
                    unit="items",
                    unit_cost=Decimal("5000.00"),
                    account_code="CAP-EQP",
                    allocation_year=timezone.now().year,
                    amount=Decimal("25000.00"),
                )
                BudgetLine.objects.create(
                    budget=cap_budget,
                    label="Field travel",
                    category=BudgetLine.Category.TRAVEL,
                    quantity=Decimal("10"),
                    unit="trips",
                    unit_cost=Decimal("500.00"),
                    account_code="CAP-TRV",
                    allocation_year=timezone.now().year,
                    amount=Decimal("5000.00"),
                )
            paid_req, _ = DisbursementRequest.objects.get_or_create(
                budget=cap_budget,
                amount=Decimal("5000.00"),
                requested_by=fo,
                defaults={
                    "justification": "Seeded executed payment for capital budget.",
                    "status": DisbursementRequest.Status.APPROVED,
                    "payment_method": DisbursementRequest.PaymentMethod.BANK_TRANSFER,
                    "scheduled_for": today - timedelta(days=10),
                    "account_reference": "BEN-CAP-001",
                    "approved_by": finance_director,
                    "approved_at": timezone.now() - timedelta(days=12),
                },
            )
            paid_req.payment_method = DisbursementRequest.PaymentMethod.BANK_TRANSFER
            paid_req.scheduled_for = paid_req.scheduled_for or (today - timedelta(days=10))
            paid_req.account_reference = paid_req.account_reference or "BEN-CAP-001"
            paid_req.approved_by = paid_req.approved_by or finance_director
            paid_req.approved_at = paid_req.approved_at or (timezone.now() - timedelta(days=12))
            if paid_req.status not in [DisbursementRequest.Status.APPROVED, DisbursementRequest.Status.PAID]:
                paid_req.status = DisbursementRequest.Status.APPROVED
            paid_req.save()
            self._seed_disbursement_finance_state(
                request=paid_req,
                execution_status=DisbursementExecution.Status.EXECUTED,
                reconciliation_status=DisbursementReconciliation.Status.RECONCILED,
                reference="TX-SEED-001",
                actor=fo,
            )
            self._seed_expenditures_for_budget(cap_budget, actor=fo, today=today)

            if not project.budget_lines.exists():
                ProjectBudgetLine.objects.create(
                    project=project,
                    label="Personnel execution",
                    allocated=Decimal("30000.00"),
                    spent=Decimal("8500.00"),
                )
                ProjectBudgetLine.objects.create(
                    project=project,
                    label="Supplies execution",
                    allocated=Decimal("8000.00"),
                    spent=Decimal("1200.00"),
                )

            if not project.risk_flags.filter(cleared=False).exists():
                RiskFlag.objects.create(
                    project=project,
                    reason="Seeded risk — reporting lag (projects connectivity demo)",
                    severity=RiskFlag.Severity.MEDIUM,
                    cleared=False,
                )

        elig_user = self._ensure_demo_user(
            "eligibility.applicant@demo.local",
            UserRole.SCHOLAR,
            inst,
            password,
        )
        # Intentionally fails PhD-only rule on e2e-eligibility-call (do not set a degree).
        self._set_profile_screening(elig_user, degree="")
        conflict_user = self._ensure_demo_user(
            "conflict.applicant@demo.local",
            UserRole.SCHOLAR,
            inst,
            password,
        )
        self._set_profile_screening(conflict_user, degree=UserProfile.DegreeLevel.PHD)
        interview_user = self._ensure_demo_user(
            "interview.applicant@demo.local",
            UserRole.SCHOLAR,
            inst,
            password,
        )
        self._set_profile_screening(interview_user, degree=UserProfile.DegreeLevel.PHD)
        blind_user = self._ensure_demo_user(
            "blind.applicant@demo.local",
            UserRole.SCHOLAR,
            inst,
            password,
        )
        self._set_profile_screening(blind_user, degree=UserProfile.DegreeLevel.PHD)

        # PRD 6.1 — automated eligibility (degree): PhD-only call, applicant has no degree on profile.
        elig_call, _ = GrantCall.objects.get_or_create(
            slug="e2e-eligibility-call",
            defaults={
                "title": "E2E eligibility — PhD required",
                "description": "Submit as eligibility.applicant@demo.local → screened ineligible (blank degree).",
                "guidelines": "Dev.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=30),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        self._publish_if_draft(elig_call)
        if not elig_call.eligibility_rules.filter(rule_type=EligibilityRule.RuleType.DEGREE).exists():
            EligibilityRule.objects.create(
                call=elig_call,
                rule_type=EligibilityRule.RuleType.DEGREE,
                params={"levels": ["phd"]},
                is_blocking=True,
            )
        self._ensure_required_documents(elig_call, DOCS_PHD_ELIGIBILITY)
        self._sync_grant_call_html(elig_call)
        app_deg, _ = Application.objects.get_or_create(
            call=elig_call,
            applicant=elig_user,
            defaults={"institution": inst},
        )
        if app_deg.status == Application.Status.DRAFT:
            submit_application(app_deg)
        self._sync_application_proposal(
            Application.objects.get(pk=app_deg.pk),
            "Statement of interest — eligibility E2E (expected ineligible after screening)",
        )

        # Interview required before award (manager UI: toggle + blocked award link when not done).
        int_call, _ = GrantCall.objects.get_or_create(
            slug="e2e-interview-call",
            defaults={
                "title": "E2E interview-before-award",
                "description": "Shortlisted application pending interview completion.",
                "guidelines": "Dev.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=60),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "interview_required": True,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        self._publish_if_draft(int_call)
        if not int_call.interview_required:
            int_call.interview_required = True
            int_call.save(update_fields=["interview_required", "updated_at"])
        int_updates = []
        if int_call.verification_required is not True:
            int_call.verification_required = True
            int_updates.append("verification_required")
        if int_call.verification_type != GrantCall.VerificationType.INSTITUTIONAL:
            int_call.verification_type = GrantCall.VerificationType.INSTITUTIONAL
            int_updates.append("verification_type")
        if int_call.reviewers_per_application != 1:
            int_call.reviewers_per_application = 1
            int_updates.append("reviewers_per_application")
        if int_updates:
            int_updates.append("updated_at")
            int_call.save(update_fields=int_updates)
        int_call.reviewer_pool.add(rev, gm)
        self._ensure_required_documents(int_call, DOCS_STANDARD)
        self._sync_grant_call_html(int_call)
        app_iv, _ = Application.objects.get_or_create(
            call=int_call,
            applicant=interview_user,
            defaults={"institution": inst},
        )
        if app_iv.status == Application.Status.DRAFT:
            submit_application(app_iv)
        app_iv = Application.objects.get(pk=app_iv.pk)
        if app_iv.status == Application.Status.UNDER_REVIEW:
            shortlist_application(app_iv)
        self._sync_application_proposal(
            Application.objects.get(pk=app_iv.pk),
            "Research proposal — interview gate E2E applicant",
        )
        interview = app_iv.interviews.order_by("-scheduled_for").first()
        if not interview:
            interview = create_or_update_interview(
                app_iv,
                scheduled_for=timezone.now() + timedelta(days=3),
                format=InterviewSchedule.Format.VIRTUAL,
                venue="RUFORUM panel room",
                notes="Seeded interview workflow for manager UI.",
                actor=gm,
                status=InterviewSchedule.Status.SCHEDULED,
            )
        verification = app_iv.verification_records.order_by("-created_at").first()
        if not verification:
            record_verification(
                app_iv,
                verification_type=VerificationRecord._meta.get_field("verification_type").default
                if not app_iv.call.verification_type
                else app_iv.call.verification_type,
                status=VerificationRecord.Status.PENDING,
                summary="Seeded institutional verification pending completion.",
                findings={"source": "seed_rims", "institution_confirmed": False},
                actor=gm,
            )

        # Divergent reviewer recommendations → conflict panel for grants manager.
        conf_call, _ = GrantCall.objects.get_or_create(
            slug="e2e-conflict-call",
            defaults={
                "title": "E2E reviewer recommendation conflict",
                "description": "Two submitted reviews with different recommendation text.",
                "guidelines": "Dev.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=60),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "reviewers_per_application": 2,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        conf_call.reviewer_pool.add(rev, rev2, gm)
        self._publish_if_draft(conf_call)
        self._ensure_required_documents(conf_call, DOCS_STANDARD)
        self._sync_grant_call_html(conf_call)
        app_cf, _ = Application.objects.get_or_create(
            call=conf_call,
            applicant=conflict_user,
            defaults={"institution": inst},
        )
        if app_cf.status == Application.Status.DRAFT:
            submit_application(app_cf)
        app_cf = Application.objects.get(pk=app_cf.pk)
        if app_cf.status == Application.Status.UNDER_REVIEW:
            self._assign_reviewer_when_under_review(app_cf, rev, declare_conflict=False)
            self._assign_reviewer_when_under_review(app_cf, rev2, declare_conflict=False)
            r1 = Review.objects.filter(application=app_cf, reviewer=rev).first()
            r2 = Review.objects.filter(application=app_cf, reviewer=rev2).first()
            if r1 and r1.submitted_at is None:
                submit_review(r1, {"Overall": Decimal("16")}, "Recommend funding; strong proposal.")
            if r2 and r2.submitted_at is None:
                submit_review(r2, {"Overall": Decimal("10")}, "Do not recommend; methodology concerns.")
        self._sync_application_proposal(
            Application.objects.get(pk=app_cf.pk),
            "Research proposal — reviewer conflict E2E applicant",
        )

        # Blind review: anonymized code on submit + reviewer queue label.
        blind_call, _ = GrantCall.objects.get_or_create(
            slug="e2e-blind-call",
            defaults={
                "title": "E2E blind review call",
                "description": "Blind review enabled; reviewer sees anonymized code.",
                "guidelines": "Dev.",
                "opens_at": timezone.now() - timedelta(days=1),
                "closes_at": timezone.now() + timedelta(days=60),
                "created_by": gm,
                "status": GrantCall.Status.DRAFT,
                "blind_review": True,
                "reviewers_per_application": 1,
                "scoring_rubric": [{"criterion": "Overall", "max_points": 20}],
            },
        )
        blind_call.reviewer_pool.add(rev, gm)
        self._publish_if_draft(blind_call)
        if not blind_call.blind_review:
            blind_call.blind_review = True
            blind_call.save(update_fields=["blind_review", "updated_at"])
        self._ensure_required_documents(blind_call, DOCS_STANDARD)
        self._sync_grant_call_html(blind_call)
        app_bl, _ = Application.objects.get_or_create(
            call=blind_call,
            applicant=blind_user,
            defaults={"institution": inst},
        )
        if app_bl.status == Application.Status.DRAFT:
            submit_application(app_bl)
        app_bl = Application.objects.get(pk=app_bl.pk)
        if app_bl.status == Application.Status.UNDER_REVIEW and not app_bl.reviews.exists():
            self._assign_reviewer_when_under_review(app_bl, rev, declare_conflict=False)
        self._sync_application_proposal(
            Application.objects.get(pk=app_bl.pk),
            "Research proposal — blind review E2E applicant",
        )

        # Scholarships: programme linked to demo call + partner; scholar row ↔ grant award (ecosystem).
        prog_desc = _html_rich_description("Scholarship programme description — IILMP demo (scholarships E2E)")
        sch, _ = Scholarship.objects.get_or_create(
            name="Demo scholarship programme (IILMP seed)",
            defaults={
                "description": prog_desc,
                "grant_call": demo_call,
                "partner": partner,
                "is_active": True,
            },
        )
        sch_updates = []
        if sch.description != prog_desc:
            sch.description = prog_desc
            sch_updates.append("description")
        if sch.grant_call_id != demo_call.id:
            sch.grant_call = demo_call
            sch_updates.append("grant_call")
        if sch.partner_id != partner.id:
            sch.partner = partner
            sch_updates.append("partner")
        if not sch.is_active:
            sch.is_active = True
            sch_updates.append("is_active")
        if sch_updates:
            sch.save(update_fields=sch_updates)

        recip_award = (
            Award.objects.filter(application__call=demo_call, application__applicant=recip).first()
        )
        if recip_award:
            scholar_row, _ = Scholar.objects.get_or_create(
                user=recip,
                scholarship=sch,
                defaults={
                    "institution": inst,
                    "award": recip_award,
                    "cohort_year": timezone.now().year,
                    "notes": "Seeded scholar ↔ grant award link (demo recipient).",
                },
            )
            su = []
            if scholar_row.institution_id != inst.id:
                scholar_row.institution = inst
                su.append("institution")
            if scholar_row.award_id != recip_award.id:
                scholar_row.award = recip_award
                su.append("award")
            if scholar_row.cohort_year != timezone.now().year:
                scholar_row.cohort_year = timezone.now().year
                su.append("cohort_year")
            if su:
                scholar_row.save(update_fields=su)

            self._seed_stipends_for_scholar(scholar_row)
            self._seed_progress_reports_for_scholar(scholar_row)
            self._maybe_seed_graduation(scholar_row)

        # Wire every seeded call to partner/MOU; scope institutions on all demo slugs (same demo uni).
        for slug, ceiling in funding_allocations.items():
            gc = GrantCall.objects.filter(slug=slug).first()
            if not gc:
                continue
            gc_updates = []
            if gc.funding_record_id != seeded_funding.id:
                gc.funding_record = seeded_funding
                gc_updates.append("funding_record")
            if gc.budget_ceiling != ceiling:
                gc.budget_ceiling = ceiling
                gc_updates.append("budget_ceiling")
            if gc_updates:
                gc_updates.append("updated_at")
                gc.save(update_fields=gc_updates)

        self._wire_grant_calls_ecosystem(partner, mou)
        scope_slugs = tuple(s for s in DEMO_GRANT_CALL_SLUGS if s != "reviewer-queue-e2e-call")
        self._scope_demo_calls_to_institution(inst, *scope_slugs)
        self._ensure_fm_budgets_for_awards_without_projects(funding_source)

        # Operations / finance master data + reviewer profiles for richer demo content.
        self._seed_strategic_objectives()
        self._seed_donors(partner=partner, actor=gm)
        self._seed_exchange_rates()
        self._seed_reviewer_profiles(rev, rev2)

        # Operations breadth: extra partners + MOUs across types/states + singletons.
        self._seed_more_partners_and_mous(inst, gm)
        self._seed_operations_singletons(gm)

        # Grants lifecycle artifacts on already-seeded applications, interview, award.
        for app in Application.objects.filter(call__slug__in=DEMO_GRANT_CALL_SLUGS).exclude(status=Application.Status.DRAFT):
            self._seed_application_documents(app)
        if interview is not None:
            self._seed_interview_outcome(interview, actor=gm)
        award_for_recipient = (
            Award.objects.filter(application__call=demo_call, application__applicant=recip).first()
        )
        if award_for_recipient is not None:
            self._seed_award_agreement_and_tranches(award_for_recipient, gm=gm, recip=recip)
            self._seed_milestone_evidence(award_for_recipient, recip=recip)
            self._seed_academic_progress_reports(award_for_recipient, recip=recip)
        self._seed_reporting_calendar(seeded_funding)

        # Projects breadth: deliverables on existing milestones, beneficiaries,
        # and a smaller secondary project so the project list has multiple rows.
        primary_project = (
            Project.objects.filter(award__application__call=demo_call, award__application__applicant=recip).first()
        )
        if primary_project is not None:
            self._seed_deliverables_for_project(primary_project, actor=gm)
            self._seed_beneficiaries_for_project(primary_project, actor=gm)
        today_local = timezone.now().date()
        self._seed_secondary_project(gm=gm, today=today_local)

        # Scholarships breadth: extra programmes, scholars, graduation record.
        self._seed_more_scholarships_and_scholars(
            inst=inst, partner=partner, password=password, demo_call=demo_call
        )

        # Finance: BudgetRevision request on the seeded operating budget.
        seed_budget = (
            Budget.objects.filter(award__application__call=demo_call, award__application__applicant=recip)
            .order_by("pk")
            .first()
        )
        if seed_budget is not None:
            self._seed_budget_revision(seed_budget, gm=gm)

        # Phase 1 — SRS §5.1/§5.4 personas need at least one live RIMS row each
        # so they land on a populated dashboard at first login.
        self._seed_phase1_persona_fixtures(
            inst=inst,
            gm=gm,
            scholar=scholar,
            seed_budget=seed_budget,
            project_manager_user=project_manager_user,
        )

        # Phase 3 — Application + reviewer integrity (FRFA-AM029).
        # Seed a reviewer self-declared COI so the manager UI shows the
        # declared-conflicts row and the reviewer's queue surfaces the
        # blocked-by-COI empty state.
        self._seed_phase3_reviewer_coi_demo(gm=gm)

    def _seed_phase3_reviewer_coi_demo(self, *, gm) -> None:
        """PRD §5.1 FRFA-AM029 — one reviewer-declared COI in demo data.

        Uses the existing blind-review e2e application so the demo also shows
        the declared-conflicts panel honouring the blind-review identity
        masking. Idempotent: only creates the COI when it doesn't exist.
        """
        from django.utils import timezone

        app_bl = (
            Application.objects.filter(call__slug="e2e-blind-call")
            .order_by("pk")
            .first()
        )
        if app_bl is None:
            return
        review = Review.objects.filter(application=app_bl).order_by("pk").first()
        if review is None:
            return
        reviewer = review.reviewer
        coi, created = ConflictOfInterest.objects.get_or_create(
            application=app_bl,
            reviewer=reviewer,
            defaults={
                "has_conflict": True,
                "notes": (
                    "Reviewer self-declared: prior co-authorship with the applicant on "
                    "an adjacent paper — judgement could be perceived as biased."
                ),
                "declared_by": reviewer,
                "declared_at": timezone.now(),
            },
        )
        if not created and not coi.has_conflict:
            coi.has_conflict = True
            coi.notes = (
                "Reviewer self-declared: prior co-authorship with the applicant on "
                "an adjacent paper — judgement could be perceived as biased."
            )
            coi.declared_by = reviewer
            coi.declared_at = timezone.now()
            coi.save(
                update_fields=["has_conflict", "notes", "declared_by", "declared_at"]
            )
