from decimal import Decimal

from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils import timezone
from django_fsm import FSMField, transition

from apps.core.authentication.models import Institution
from apps.core.money import DEFAULT_CURRENCY, ISO4217_CHOICES
from apps.core.storage import FileValidator
from apps.core.storage.paths import upload_to_grants

_grant_doc_validator = FileValidator(
    module="rims",
    allowed_categories=["document", "data", "archive"],
)


# ── Psychometric / vulnerability assessment choice sets ──────────────────────
# Old-system parity: legacy scholarships.ApplicantHouseHoldSurvey ("Psychometric
# Test Model") and EnumeratorHouseHoldSurvey / ParentHomeValidationSurvey choice
# constants, ported verbatim. Defined at module level (rather than inline on
# PsychometricAssessment) so HomeValidationEnumeratorDetail / ParentDetail can
# reuse SCALE_ONE_TO_TEN_CHOICES too.
LIKE_ME_CHOICES = (
    (0, "Not Like Me At All"),
    (1, "Somewhat Like Me"),
    (2, "More Like Me"),
    (3, "Extremely Like Me"),
)

IMPORTANT_CHOICES = (
    (0, "Not Important At All"),
    (1, "Somewhat Important"),
    (2, "Very Important"),
    (3, "Extremely Important"),
)

TRUE_CHOICES = (
    (0, "Not True At All"),
    (1, "Somewhat True"),
    (2, "Very True"),
    (3, "Extremely True"),
)

OFTEN_CHOICES = (
    (0, "Almost Never"),
    (1, "Rarely"),
    (2, "Seldom"),
    (3, "Once in a While"),
    (4, "Occasionally"),
    (5, "Sometimes"),
    (6, "Fairly"),
    (7, "Fairly Often"),
    (8, "Usually"),
    (9, "Very Frequently"),
    (10, "Almost Always"),
)

AGREE_CHOICES = (
    (-1, "Strongly Disagree"),
    (0, "Disagree"),
    (1, "Neither agree nor disagree"),
    (2, "Agree"),
    (3, "Strongly Agree"),
)

# Old system named this "ZERO_To_TEN_CHOICES" but the tuple itself runs 1-10.
SCALE_ONE_TO_TEN_CHOICES = tuple((n, n) for n in range(1, 11))


class PrototypeStatus(models.TextChoices):
    """Shared between ChallengeApplicationProfile and ChallengeMilestoneReport."""

    CONCEPT = "concept", "Concept only"
    WORKING_PROTOTYPE = "working_prototype", "Working prototype"
    FIELD_TESTED = "field_tested", "Field tested"
    MARKET_READY = "market_ready", "Market ready"


class StrategicObjective(models.Model):
    """PRD §5.1 FRFA-GPS006 — structured strategic objective taxonomy.

    Funding records link to zero-or-many strategic objectives via
    :attr:`FundingRecord.strategic_objective_refs`. Administrators curate the
    list via Django admin. The slug field gives stable IDs for reporting.
    """

    name = models.CharField(max_length=255, unique=True)
    slug = models.SlugField(max_length=120, unique=True)
    description = models.TextField(blank=True)
    active = models.BooleanField(default=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class FundingRecord(models.Model):
    """Parent funding / grant planning record (PRD §5.1.1) — calls draw committed balance when published."""

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PENDING_APPROVAL = "pending_approval", "Pending approval"
        DRAFT_REVISION_REQUIRED = "draft_revision_required", "Draft revision required"
        APPROVED = "approved", "Approved"
        CLOSED = "closed", "Closed"

    class ReportingFrequency(models.TextChoices):
        MONTHLY = "monthly", "Monthly"
        QUARTERLY = "quarterly", "Quarterly"
        SEMI_ANNUAL = "semi_annual", "Semi-annual"
        ANNUAL = "annual", "Annual"
        AD_HOC = "ad_hoc", "Ad hoc"
        # PRD §5.1.1.1 Table 14 — academic-cycle reporting for scholarship-
        # funded awards (per semester / academic year).
        ACADEMIC_SEMESTER = "academic_semester", "Per academic semester"
        ACADEMIC_YEAR = "academic_year", "Per academic year"

    public_grant_id = models.CharField(
        max_length=40,
        unique=True,
        blank=True,
        db_index=True,
        help_text="Unique Grant ID (assigned when submitted for approval / on approval).",
    )
    title = models.CharField(max_length=255)
    partner = models.ForeignKey(
        "rims_operations.Partner",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records",
    )
    # PRD §5.1 FRFA-GPS007–009 — donor profile linkage. Kept nullable so legacy
    # funding records that only have `partner` continue to load. New records
    # should prefer `donor`.
    donor = models.ForeignKey(
        "rims_operations.Donor",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records",
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    start_date = models.DateField()
    end_date = models.DateField()
    strategic_objectives = models.TextField(
        blank=True,
        help_text=(
            "Legacy free-text list of strategic objectives. New records should "
            "populate `strategic_objective_refs` instead; this field is kept "
            "for backward compatibility and is auto-mirrored on save."
        ),
    )
    strategic_objective_refs = models.ManyToManyField(
        "StrategicObjective",
        blank=True,
        related_name="funding_records",
        help_text="PRD §5.1 FRFA-GPS006 — structured link to strategic objectives.",
    )
    reporting_financial_frequency = models.CharField(
        max_length=24,
        choices=ReportingFrequency.choices,
        default=ReportingFrequency.QUARTERLY,
    )
    reporting_narrative_frequency = models.CharField(
        max_length=24,
        choices=ReportingFrequency.choices,
        default=ReportingFrequency.QUARTERLY,
    )
    grant_manager = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records_as_grant_manager",
    )
    finance_officer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records_as_finance_officer",
    )
    program_lead = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records_as_program_lead",
    )
    status = models.CharField(
        max_length=32,
        choices=Status.choices,
        default=Status.DRAFT,
        db_index=True,
    )
    rejection_comment = models.TextField(blank=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_records_created",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.public_grant_id or self.title

    def clean(self):
        super().clean()
        if self.start_date and self.end_date and self.start_date > self.end_date:
            raise ValidationError({"end_date": "End date must be on or after start date."})
        if self.pk:
            lines = list(self.budget_lines.all())
            if lines:
                total = sum((line.line_total for line in lines), Decimal("0"))
                if total != self.amount:
                    raise ValidationError(
                        {
                            "amount": (
                                f"The sum of budget line totals ({total}) must match the grant amount ({self.amount})."
                            )
                        }
                    )


class GrantBudgetLine(models.Model):
    """Line-item budget under a funding record (PRD grant planning — totals must match amount)."""

    funding_record = models.ForeignKey(
        FundingRecord,
        on_delete=models.CASCADE,
        related_name="budget_lines",
    )
    category = models.CharField(max_length=128, blank=True)
    description = models.CharField(max_length=255)
    unit_of_measure = models.CharField(max_length=64, blank=True)
    quantity = models.DecimalField(max_digits=14, decimal_places=4, default=Decimal("1"))
    unit_cost = models.DecimalField(max_digits=14, decimal_places=2)
    sort_order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["sort_order", "pk"]

    @property
    def line_total(self) -> Decimal:
        return (self.quantity * self.unit_cost).quantize(Decimal("0.01"))


class GrantCallQuerySet(models.QuerySet):
    """Custom queryset for GrantCall — single seam for cross-module filters.

    Scholarship views (``apps.rims.scholarships``) consume ``scholarship_calls``
    so the model can later split into a dedicated ``ScholarshipCall`` without
    rewriting callers. See ``apps/rims/scholarships/PRD_MAPPING.md``.
    """

    def scholarship_calls(self):
        return self.filter(call_type=GrantCall.CallType.SCHOLARSHIP)

    def published(self):
        return self.filter(status=GrantCall.Status.PUBLISHED)


class GrantCall(models.Model):
    """Published funding / scholarship / fellowship call."""

    objects = GrantCallQuerySet.as_manager()

    class CallType(models.TextChoices):
        GRANT = "grant", "Grant"
        SCHOLARSHIP = "scholarship", "Scholarship"
        FELLOWSHIP = "fellowship", "Fellowship"
        CHALLENGE = "challenge", "Challenge"

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"
        CLOSED = "closed", "Closed"
        AWARDING = "awarding", "Awarding"
        COMPLETED = "completed", "Completed"
        WITHDRAWN = "withdrawn", "Withdrawn"  # PRD §5.1 FRFA-CS030

    class TieBreakPolicy(models.TextChoices):
        MANAGER_REVIEW = "manager_review", "Manager review"
        EARLIEST_SUBMISSION = "earliest_submission", "Earliest complete submission"
        INTERVIEW_SCORE = "interview_score", "Interview score"

    class ReviewerWorkloadMode(models.TextChoices):
        BALANCED = "balanced", "Balanced"
        ROUND_ROBIN = "round_robin", "Round robin"
        MANUAL = "manual", "Manual"

    class ConflictResolution(models.TextChoices):
        """PRD §5.1 FRFA-AM027–028 — automated reviewer-conflict resolution policy."""

        MAJORITY = "majority", "Majority recommendation"
        AVERAGE_THRESHOLD = "average_threshold", "Average score threshold"
        ESCALATE = "escalate", "Escalate to manager"

    class VerificationType(models.TextChoices):
        NONE = "none", "None"
        PHONE = "phone", "Phone"
        HOUSEHOLD = "household", "Household"
        INSTITUTIONAL = "institutional", "Institutional"
        OTHER = "other", "Other"

    title = models.CharField(max_length=255)
    slug = models.SlugField(max_length=120, unique=True, db_index=True)
    call_type = models.CharField(max_length=32, choices=CallType.choices, default=CallType.GRANT)
    description = models.TextField(blank=True)
    guidelines = models.TextField(blank=True)
    opens_at = models.DateTimeField(default=timezone.now)
    closes_at = models.DateTimeField()
    interview_required = models.BooleanField(default=False)
    # PRD §5.1 FRFA-CS021 / Table 14 — opt-in budget rule.
    # When True, applicants on a GRANT call must include a budget attachment;
    # SCHOLARSHIP / FELLOWSHIP / CHALLENGE calls always reject budget
    # attachments at submission regardless of this flag (budgets are deferred
    # to the award stage). Default False so legacy calls keep their previous
    # lenient behaviour; staff opt in per call as they prepare real grants.
    applicant_budget_required = models.BooleanField(
        default=False,
        help_text=(
            "Grant calls only: when enabled, applicants must include a budget attachment "
            "(ApplicationDocument with is_budget=True) before submission can succeed."
        ),
    )
    # PRD §5.1 FRFA-CS013 / Table 14 — scholarship-specific eligibility fields.
    # The submission form surfaces them only when call_type=SCHOLARSHIP.
    scholarship_university_required = models.BooleanField(
        default=False,
        help_text="Scholarship calls only: applicant must select a target university.",
    )
    scholarship_course_required = models.BooleanField(
        default=False,
        help_text="Scholarship calls only: applicant must select a target course.",
    )
    scholarship_cohort_year = models.PositiveSmallIntegerField(
        null=True,
        blank=True,
        help_text="Scholarship calls only: target cohort intake year (e.g. 2026).",
    )
    # PRD §5.1 FRFA-CS005 / Table 14 — fellowship-specific call configuration.
    fellowship_host_institution_required = models.BooleanField(
        default=False,
        help_text="Fellowship calls only: applicant must name a host institution.",
    )
    fellowship_term_months = models.PositiveSmallIntegerField(
        null=True,
        blank=True,
        help_text="Fellowship calls only: expected term length in months.",
    )
    # PRD §5.1 FRFA-CS005 / Table 14 — challenge-specific call configuration.

    class InnovationStage(models.TextChoices):
        IDEATION = "ideation", "Ideation"
        PROTOTYPE = "prototype", "Prototype"
        PILOT = "pilot", "Pilot"
        SCALE = "scale", "Scale"

    challenge_innovation_stage = models.CharField(
        max_length=24,
        choices=InnovationStage.choices,
        blank=True,
        help_text="Challenge calls only: innovation-readiness stage targeted by this call.",
    )
    challenge_sector_focus = models.CharField(
        max_length=120,
        blank=True,
        help_text="Challenge calls only: sector or thematic focus (e.g. agritech, fintech).",
    )
    # Challenge prize tiers — simple JSON list so we don't need a separate model.
    # Format: [{"place": "1st", "label": "Winner", "amount": 5000, "currency": "USD"}, ...]
    challenge_prize_tiers = models.JSONField(
        default=list,
        blank=True,
        help_text=(
            "Challenge calls only: prize tiers. "
            'Each entry: {"place": "1st", "label": "Winner", "amount": 5000, "currency": "USD"}.'
        ),
    )
    # PRD §5.1 FRFA-CS020 — per-call submission settings (applicant uploads).
    # Defaults are conservative; staff tune per call. ``_grant_doc_validator``
    # in ``services`` reads these when validating applicant uploads.
    applicant_attachment_max_size_mb = models.PositiveSmallIntegerField(
        default=10,
        blank=True,
        help_text="Maximum size per applicant attachment, in megabytes.",
    )
    applicant_attachment_formats = models.CharField(
        max_length=120,
        default="pdf,docx",
        blank=True,
        help_text="Comma-separated allowed file extensions (no dot, lower-case). e.g. pdf,docx,xlsx.",
    )
    applicant_attachment_checklist = models.JSONField(
        default=list,
        blank=True,
        help_text="Optional checklist of expected applicant attachments, e.g. [\"CV\", \"Cover letter\"].",
    )
    budget_ceiling = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    blind_review = models.BooleanField(default=False, help_text="Hide applicant identity from reviewers")
    # PRD §5.1 FRFA-AM013–017 — when True, submitted applications pause at
    # SUBMITTED after automated eligibility and require an explicit Program
    # Officer decision (document-completeness screening) before entering the
    # reviewer queue. Defaults to False so legacy calls keep their auto-advance
    # behaviour; opt-in per-call as staff are trained on the new gate.
    programme_officer_screening_enabled = models.BooleanField(default=False)
    # PRD §5.1 FRFA-AM038 — admin-controlled toggle for the rejection email.
    # When True, the rejection notification template can include the reviewer
    # comments via {% if include_reviewer_comments %}{{ reviewer_comments }}.
    include_reviewer_comments_in_decision_email = models.BooleanField(
        default=False,
        help_text=(
            "When enabled, rejection emails on this call can render reviewer comments. "
            "The actual content is controlled by the per-verb NotificationTemplate."
        ),
    )
    scoring_rubric = models.JSONField(
        default=list,
        blank=True,
        help_text='List of {"criterion": str, "max_points": number}',
    )
    # PRD §5.1 FRFA-CS030 — call withdrawal audit trail.
    withdrawal_reason = models.TextField(blank=True)
    withdrawn_at = models.DateTimeField(null=True, blank=True)
    withdrawn_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_calls_withdrawn",
    )
    # PRD §5.1 FRFA-CS022 — admin confirmation that the call will auto-close at
    # the configured deadline. Set when the publish form is POSTed with the
    # acknowledge-auto-close checkbox; ``publish_call`` blocks publish otherwise.
    auto_close_confirmed_at = models.DateTimeField(
        null=True,
        blank=True,
        help_text="Set when the administrator acknowledges the auto-close behaviour at publish time.",
    )
    status = FSMField(
        max_length=32,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_calls_created",
    )
    partner = models.ForeignKey(
        "rims_operations.Partner",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_calls",
        help_text="Primary donor/funder (operations master data).",
    )
    mou = models.ForeignKey(
        "rims_operations.MOU",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_calls",
        help_text="Governing memorandum of understanding, when applicable.",
    )
    funding_record = models.ForeignKey(
        FundingRecord,
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name="calls",
        help_text="When set, the call draws its allocation from this approved funding record.",
    )
    review_starts_at = models.DateTimeField(
        null=True,
        blank=True,
        help_text="When set, must be after the submission deadline (closes_at).",
    )
    review_ends_at = models.DateTimeField(null=True, blank=True, help_text="Review period end.")
    max_awards = models.PositiveIntegerField(
        null=True,
        blank=True,
        help_text="Optional cap on how many awards this call may make.",
    )
    reviewers_per_application = models.PositiveSmallIntegerField(default=2)
    review_score_threshold = models.DecimalField(
        max_digits=8,
        decimal_places=2,
        null=True,
        blank=True,
        help_text="Minimum average score required before shortlist recommendation.",
    )
    tie_break_policy = models.CharField(
        max_length=32,
        choices=TieBreakPolicy.choices,
        default=TieBreakPolicy.MANAGER_REVIEW,
    )
    conflict_resolution = models.CharField(
        max_length=32,
        choices=ConflictResolution.choices,
        default=ConflictResolution.ESCALATE,
        help_text=(
            "PRD §5.1 FRFA-AM027–028. How the system resolves divergent reviewer "
            "recommendations: majority vote, average-score threshold, or manual "
            "escalation to the call manager."
        ),
    )
    reviewer_workload_mode = models.CharField(
        max_length=24,
        choices=ReviewerWorkloadMode.choices,
        default=ReviewerWorkloadMode.BALANCED,
    )
    review_deadline_days = models.PositiveSmallIntegerField(
        default=14,
        help_text="Default reviewer deadline, counted from assignment.",
    )
    verification_required = models.BooleanField(default=False)
    verification_type = models.CharField(
        max_length=24,
        choices=VerificationType.choices,
        default=VerificationType.NONE,
    )
    verification_score_weight = models.DecimalField(
        max_digits=5,
        decimal_places=2,
        default=Decimal("0.00"),
        help_text="Optional additional score contribution from verification/interview workflow.",
    )
    institutions_scope = models.ManyToManyField(
        Institution,
        blank=True,
        related_name="scoped_calls",
        help_text="Empty = all institutions eligible",
    )
    reviewer_pool = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="grant_calls_as_reviewer_pool",
        help_text="Preferred reviewers for automatic assignment. Empty = any reviewer-capable user.",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-opens_at"]

    def __str__(self):
        return self.title

    @transition(field=status, source=Status.DRAFT, target=Status.PUBLISHED)
    def publish(self):
        pass

    @transition(field=status, source=Status.PUBLISHED, target=Status.CLOSED)
    def close(self):
        pass

    @transition(field=status, source=Status.PUBLISHED, target=Status.AWARDING)
    def start_awarding(self):
        pass

    @transition(field=status, source=Status.AWARDING, target=Status.COMPLETED)
    def complete(self):
        pass

    @transition(field=status, source=Status.CLOSED, target=Status.AWARDING)
    def reopen_for_awarding(self):
        pass

    @transition(
        field=status,
        source=[Status.PUBLISHED, Status.DRAFT],
        target=Status.WITHDRAWN,
    )
    def withdraw(self):
        """PRD §5.1 FRFA-CS030 — withdraw a published or draft call. The
        ``withdraw_call`` service wraps this to handle the side effects
        (release committed balance, notify applicants, suspend submitted
        applications)."""
        pass

    def clean(self):
        """PRD §5.1 FRFA-CS006 — call_type is immutable after publication."""
        super().clean()
        if not self.pk:
            return
        if self.status in (
            GrantCall.Status.PUBLISHED,
            GrantCall.Status.CLOSED,
            GrantCall.Status.AWARDING,
            GrantCall.Status.COMPLETED,
        ):
            try:
                prior = GrantCall.objects.only("call_type").get(pk=self.pk)
            except GrantCall.DoesNotExist:
                return
            if prior.call_type != self.call_type:
                raise ValidationError(
                    {
                        "call_type": (
                            "Call type cannot be changed after the call is published. "
                            "Withdraw this call and create a new one if the classification is wrong."
                        )
                    }
                )


class ReviewCriterion(models.Model):
    """Structured evaluation criterion for a call.

    Replaces the free-form JSON blob on Review.scores with named,
    weighted criteria so reviewers score each dimension explicitly and
    managers can validate that weights sum to 100 before publishing.
    """

    class ScoringMethod(models.TextChoices):
        NUMERIC = "numeric", "Numeric (0 – max)"
        PASS_FAIL = "pass_fail", "Pass / Fail"

    call = models.ForeignKey(
        "GrantCall",
        on_delete=models.CASCADE,
        related_name="review_criteria",
    )
    name = models.CharField(max_length=150, help_text="e.g. Research Quality, Feasibility")
    description = models.TextField(blank=True, help_text="Guidance shown to reviewers when scoring this criterion.")
    scoring_method = models.CharField(
        max_length=16,
        choices=ScoringMethod.choices,
        default=ScoringMethod.NUMERIC,
    )
    max_score = models.PositiveSmallIntegerField(
        default=10,
        help_text="Maximum points for this criterion (ignored for Pass/Fail).",
    )
    weight = models.DecimalField(
        max_digits=5,
        decimal_places=2,
        default=0,
        help_text="Percentage weight (all criteria must sum to 100).",
    )
    sort_order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["sort_order", "pk"]
        constraints = [
            models.CheckConstraint(
                condition=models.Q(weight__gte=0, weight__lte=100),
                name="review_criterion_weight_range",
            ),
        ]

    def __str__(self):
        return f"{self.call.slug} — {self.name}"


class RequiredDocument(models.Model):
    """A document that applicants must upload when applying to this call."""

    call = models.ForeignKey(GrantCall, on_delete=models.CASCADE, related_name="required_documents")
    label = models.CharField(max_length=128, help_text="e.g. CV / Résumé, Research proposal, Reference letter")
    description = models.CharField(max_length=255, blank=True, help_text="Brief guidance shown to applicants")
    is_required = models.BooleanField(default=True)
    sort_order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["sort_order", "pk"]

    def __str__(self):
        return self.label


class CallNotificationConfig(models.Model):
    """PRD §5.1 FRFA-CS023 — per-call notification configuration.

    Holds toggles for publication, deadline reminders, and applicant
    acknowledgement notifications. Defaults are sensible so an unset config
    (or a missing row) does not silently disable existing call-published
    flow on legacy calls — ``get_for_call`` returns a virtual default config
    when no row exists.

    ``reminder_intervals_days`` is a JSON list of integers (e.g. ``[7, 1]``)
    meaning "remind in-progress applicants 7 days and 1 day before the
    deadline". Empty list disables deadline reminders.
    """

    call = models.OneToOneField(
        GrantCall,
        on_delete=models.CASCADE,
        related_name="notification_config",
    )
    publication_notify_enabled = models.BooleanField(
        default=True,
        help_text="When enabled, dispatch a notification to potential applicants on publish.",
    )
    reminder_intervals_days = models.JSONField(
        default=list,
        blank=True,
        help_text="Days-before-deadline at which to remind in-progress applicants. e.g. [7, 1].",
    )
    acknowledgement_enabled = models.BooleanField(
        default=True,
        help_text="When enabled, send an acknowledgement notification to the applicant on submit.",
    )
    updated_at = models.DateTimeField(auto_now=True)

    DEFAULT_REMINDER_DAYS = [7, 1]

    def get_reminder_intervals(self) -> list[int]:
        intervals = self.reminder_intervals_days
        if not intervals:
            return list(self.DEFAULT_REMINDER_DAYS)
        # Coerce + dedupe + sort descending (longest lead first).
        out: set[int] = set()
        for v in intervals:
            try:
                iv = int(v)
            except (TypeError, ValueError):
                continue
            if iv > 0:
                out.add(iv)
        return sorted(out, reverse=True)

    @classmethod
    def get_for_call(cls, call: "GrantCall") -> "CallNotificationConfig":
        cfg = getattr(call, "notification_config", None)
        if cfg is not None:
            return cfg
        # Return an unsaved default config so callers can read get_reminder_intervals
        # / flags without coupling tasks to a "config exists" precondition.
        return cls(call=call)


class GrantCallDocument(models.Model):
    """PRD §5.1 FRFA-CS019 — call supporting documents with version history.

    When a manager uploads a revision of an existing document, the prior row
    is kept (superseded=True) and `supersedes` is set on the new row; queries
    for "current" documents filter `superseded=False`. `version` is a
    monotonically increasing integer within the (call, label) group.
    """

    # PRD §5.1 FRFA-CS016 — required admin documents per call type. Values must
    # match ``constants.CALL_TYPE_REQUIRED_ADMIN_DOCS``.
    class DocKind(models.TextChoices):
        GUIDELINES = "guidelines", "Guidelines"
        PROPOSAL_TEMPLATE = "proposal_template", "Proposal template"
        BUDGET_TEMPLATE = "budget_template", "Budget template"
        TC = "tc", "Terms & conditions"
        INSTRUCTIONS = "instructions", "Application instructions"
        FELLOWSHIP_TERMS = "fellowship_terms", "Fellowship terms"
        HOST_AGREEMENT = "host_agreement", "Host institution agreement"
        INNOVATION_BRIEF = "innovation_brief", "Innovation brief"
        JUDGING_CRITERIA = "judging_criteria", "Judging criteria"
        OTHER = "other", "Other"

    call = models.ForeignKey(GrantCall, on_delete=models.CASCADE, related_name="documents")
    label = models.CharField(max_length=128)
    file = models.FileField(upload_to=upload_to_grants, validators=[_grant_doc_validator])
    doc_kind = models.CharField(
        max_length=32,
        choices=DocKind.choices,
        default=DocKind.OTHER,
        db_index=True,
        help_text="Classifies the document so publish-time checks know which required types are present (PRD FRFA-CS016).",
    )
    is_guideline = models.BooleanField(
        default=True,
        help_text="When checked, this file satisfies the “official guideline document” requirement for publishing.",
    )
    sort_order = models.PositiveSmallIntegerField(default=0)
    version = models.PositiveIntegerField(default=1)
    supersedes = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="supersedes_reverse",
    )
    superseded = models.BooleanField(default=False, db_index=True)
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_call_documents_uploaded",
    )
    # Nullable so adding this field to a table with pre-existing rows does
    # not require a one-time default; new rows auto-populate via `default`.
    uploaded_at = models.DateTimeField(default=timezone.now, null=True, blank=True)

    class Meta:
        ordering = ["sort_order", "pk"]


class EligibilityRule(models.Model):
    class RuleType(models.TextChoices):
        # FRFA-CS012 — base criteria, applicable to all call types.
        NATIONALITY = "nationality", "Nationality ISO list"
        DEGREE = "degree", "Degree level"
        INSTITUTION = "institution", "Institution allow-list"
        SECTOR = "sector", "Sector / thematic area"
        AGE = "age", "Age restriction"
        DOCUMENTATION = "documentation", "Required documentation checklist"
        # FRFA-CS013 — type-specific criteria.
        SCHOLARSHIP_UNIVERSITY = "scholarship_university", "Scholarship: target university allow-list"
        SCHOLARSHIP_COURSE = "scholarship_course", "Scholarship: target course keywords"
        FELLOWSHIP_HOST_INSTITUTION = "fellowship_host_institution", "Fellowship: host institution allow-list"
        FELLOWSHIP_TYPE = "fellowship_type", "Fellowship: type (form of service)"
        CHALLENGE_INNOVATION_STAGE = "challenge_innovation_stage", "Challenge: innovation stage"

    call = models.ForeignKey(GrantCall, on_delete=models.CASCADE, related_name="eligibility_rules")
    rule_type = models.CharField(max_length=32, choices=RuleType.choices)
    # e.g. {"countries": ["Uganda","Kenya"]}, {"levels": ["masters"]}, {"institution_ids": [1,2]}
    params = models.JSONField(default=dict)
    is_blocking = models.BooleanField(default=True, help_text="If true, failed rule prevents review queue")

    class Meta:
        ordering = ["pk"]


class Application(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        INELIGIBLE = "ineligible", "Ineligible"
        UNDER_REVIEW = "under_review", "Under review"
        SHORTLISTED = "shortlisted", "Shortlisted"
        APPROVED = "approved", "Approved"
        AWARDED = "awarded", "Awarded"
        REJECTED = "rejected", "Rejected"
        # PRD §5.1 FRFA-AM010 — extended terminal/locked states
        WITHDRAWN = "withdrawn", "Withdrawn by applicant"
        WITHDRAWN_NO_RESPONSE = "withdrawn_no_response", "Withdrawn — no response"
        REJECTED_INELIGIBLE = "rejected_ineligible", "Rejected — ineligible"
        EXPIRED_DRAFT = "expired_draft", "Expired draft"

    call = models.ForeignKey(GrantCall, on_delete=models.CASCADE, related_name="applications")
    applicant = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="grant_applications",
    )
    institution = models.ForeignKey(
        Institution,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_applications",
        help_text=(
            "Primary institutional affiliation. Additional affiliations live in "
            "`institutions` (PRD §4.3.1 — multi-institution applicants)."
        ),
    )
    institutions = models.ManyToManyField(
        Institution,
        blank=True,
        related_name="grant_applications_affiliated",
        help_text="PRD §4.3.1 — additional institutional affiliations (non-primary).",
    )
    proposal = models.TextField(
        blank=True,
        help_text="Project proposal, cover letter, or statement of purpose.",
    )
    anonymized_code = models.CharField(
        max_length=16,
        blank=True,
        db_index=True,
        help_text="Blind review reference",
    )
    status = FSMField(
        max_length=32,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    eligibility_notes = models.TextField(blank=True)
    # PRD §5.1 FRFA-AM009 — structured per-rule trace of automated screening.
    # Each entry: {rule_id, rule_label, expected, actual, passed, evaluated_at}.
    # eligibility_notes is composed from this trace for backward-compat.
    eligibility_trace = models.JSONField(default=list, blank=True)
    # PRD §5.1 FRFA-AM013 — true when automated eligibility passes; remains
    # true while the application waits for Program Officer screening.
    eligibility_passed = models.BooleanField(default=False)
    interview_completed = models.BooleanField(
        default=False,
        help_text="Manager confirms interview completed when call.interview_required is set.",
    )
    final_decision_comment = models.TextField(blank=True)
    shortlist_override_reason = models.TextField(blank=True)
    ranking_score = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
    ranking_position = models.PositiveIntegerField(null=True, blank=True)
    tied_in_ranking = models.BooleanField(default=False)
    shortlist_recommended = models.BooleanField(default=False)
    review_conflict_acknowledged = models.BooleanField(
        default=False,
        help_text="Call manager acknowledged divergent reviewer recommendations.",
    )
    revision_requested_at = models.DateTimeField(
        null=True,
        blank=True,
        help_text="When set, the applicant may edit their application after it was submitted.",
    )
    revision_note = models.TextField(blank=True, help_text="Instructions from grants manager to the applicant.")
    # PRD §5.1 FRFA-CS013 — scholarship-specific application fields. Filled
    # only when call.call_type == SCHOLARSHIP and the corresponding required
    # flag is set on the call.
    scholarship_university_choice = models.ForeignKey(
        "core.Institution",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
        help_text="Scholarship applications: target university (required when call.scholarship_university_required).",
    )
    scholarship_course_choice = models.CharField(
        max_length=255,
        blank=True,
        help_text="Scholarship applications: target course / programme name.",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)

    # ── Psychometric test (scholarship calls only) ──────────────────────────
    # Staff enter the score after the applicant completes the external test.
    # No integration needed — just record the result.
    psychometric_score = models.DecimalField(
        max_digits=6,
        decimal_places=2,
        null=True,
        blank=True,
        help_text="Scholarship only: psychometric test score entered by staff.",
    )
    psychometric_recorded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
        help_text="Staff member who recorded the psychometric score.",
    )
    psychometric_recorded_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["call", "applicant"], name="uniq_application_per_call_user"),
        ]
        ordering = ["-created_at"]

    def __str__(self):
        return f"{self.call.slug} — {self.applicant.email}"

    @transition(field=status, source=Status.DRAFT, target=Status.SUBMITTED)
    def submit(self):
        self.submitted_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.INELIGIBLE)
    def mark_ineligible(self):
        pass

    @transition(field=status, source=Status.SUBMITTED, target=Status.UNDER_REVIEW)
    def pass_screening(self):
        pass

    @transition(field=status, source=Status.UNDER_REVIEW, target=Status.SHORTLISTED)
    def shortlist(self):
        pass

    @transition(field=status, source=Status.SHORTLISTED, target=Status.APPROVED)
    def approve(self):
        pass

    @transition(field=status, source=[Status.SHORTLISTED, Status.APPROVED], target=Status.AWARDED)
    def mark_awarded(self):
        pass

    @transition(field=status, source="*", target=Status.REJECTED)
    def reject(self):
        pass

    # PRD §5.1 FRFA-AM010 extended transitions.
    @transition(
        field=status,
        source=[Status.DRAFT, Status.SUBMITTED, Status.UNDER_REVIEW, Status.SHORTLISTED],
        target=Status.WITHDRAWN,
    )
    def withdraw(self):
        """Applicant self-withdraws before award."""
        pass

    @transition(
        field=status,
        source=[Status.SUBMITTED, Status.UNDER_REVIEW],
        target=Status.WITHDRAWN_NO_RESPONSE,
    )
    def mark_no_response_withdrawal(self):
        """System auto-withdraws an application whose applicant missed a
        revision deadline or similar required response."""
        pass

    @transition(field=status, source=Status.SUBMITTED, target=Status.REJECTED_INELIGIBLE)
    def reject_ineligible(self):
        """Eligibility-driven rejection — distinct from merit rejection so
        reporting can distinguish ineligible submissions from reviewed-and-rejected."""
        pass

    @transition(field=status, source=Status.DRAFT, target=Status.EXPIRED_DRAFT)
    def expire_draft(self):
        """Called by the auto-close sweep (PRD FRFA-CS031 / NFRFA010) for
        drafts left unsubmitted when the call's deadline passes."""
        pass


class ApplicationDocument(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="documents")
    required_document = models.ForeignKey(
        "RequiredDocument", null=True, blank=True, on_delete=models.SET_NULL, related_name="application_documents"
    )
    label = models.CharField(max_length=128)
    file = models.FileField(upload_to=upload_to_grants, validators=[_grant_doc_validator])
    # PRD §5.1 FRFA-CS021 — flagged when this document is the applicant's
    # budget submission, used by submit_application to enforce the per-call-
    # type budget rules. Set by the form / view that handles the upload.
    is_budget = models.BooleanField(default=False)
    uploaded_at = models.DateTimeField(auto_now_add=True)


class ApplicationCollaborator(models.Model):
    """Named collaborator (with CV) on a grant application.

    Old-system parity: legacy ``grants_applications.Collaborator``.
    """

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="collaborators")
    full_name = models.CharField(max_length=200)
    role = models.CharField(max_length=200, blank=True)
    cv = models.FileField(upload_to=upload_to_grants, blank=True, null=True)

    def __str__(self):  # pragma: no cover
        return self.full_name


class Review(models.Model):
    class RecommendationCode(models.TextChoices):
        APPROVE = "approve", "Approve"
        REVISE = "revise", "Revise"
        REJECT = "reject", "Reject"
        HOLD = "hold", "Hold for clarification"

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="reviews")
    reviewer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="grant_reviews",
    )
    scores = models.JSONField(default=dict, help_text="ReviewCriterion pk → score value (numeric or 'pass'/'fail')")
    total_score = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal("0"))
    recommendation_code = models.CharField(
        max_length=16,
        choices=RecommendationCode.choices,
        blank=True,
    )
    recommendation = models.TextField(blank=True)
    assigned_at = models.DateTimeField(default=timezone.now)
    due_at = models.DateTimeField(null=True, blank=True)
    assignment_sequence = models.PositiveIntegerField(default=0)
    assigned_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_reviews_assigned",
    )
    # Reviewer acceptance gate: None = pending, True = accepted, False = declined.
    accepted = models.BooleanField(null=True, blank=True, default=None)
    accepted_at = models.DateTimeField(null=True, blank=True)
    submitted_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["application", "reviewer"], name="uniq_review_per_reviewer"),
        ]


class ConflictOfInterest(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="conflicts")
    reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    has_conflict = models.BooleanField(default=False)
    notes = models.TextField(blank=True)
    # PRD §5.1 FRFA-AM029 — when the reviewer self-declares, capture who/when.
    # When NULL the row was recorded by the Programme Officer via assignment.
    declared_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="cois_declared",
    )
    declared_at = models.DateTimeField(null=True, blank=True)


class HomeValidationRecord(models.Model):
    """Staff-recorded outcome of a home / household validation visit.

    Scholarship calls only. Instead of a complex external-validator workflow,
    a staff member visits (or calls) and records findings directly here.
    """

    class Recommendation(models.TextChoices):
        PASS = "pass", "Pass"
        FAIL = "fail", "Fail"
        BORDERLINE = "borderline", "Borderline — refer to committee"

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="home_validation",
    )
    conducted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        related_name="home_validations_conducted",
    )
    visit_date = models.DateField()
    household_income_estimate = models.CharField(
        max_length=100, blank=True,
        help_text="e.g. 'Below USD 200/month', 'Subsistence farming'",
    )
    number_of_dependants = models.PositiveSmallIntegerField(null=True, blank=True)
    housing_condition = models.CharField(
        max_length=255, blank=True,
        help_text="Brief description — e.g. 'Mud brick, iron sheet roof, no electricity'",
    )
    key_findings = models.TextField(help_text="Observations and context from the visit.")
    recommendation = models.CharField(max_length=16, choices=Recommendation.choices)
    recorded_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-recorded_at"]

    def __str__(self):
        return f"Home validation: {self.application}"


class HomeValidationEnumeratorDetail(models.Model):
    """Structured enumerator findings for a home validation visit.

    Old-system parity: legacy ``scholarships.EnumeratorHouseHoldSurvey``.
    Companion to ``HomeValidationRecord`` (1:1) rather than a replacement —
    ``HomeValidationRecord.key_findings``/``recommendation`` remain the
    summary/decision fields used by downstream screening logic; this model
    supplies the structured detail behind that summary.
    """

    class PsychometricTestCompletion(models.TextChoices):
        EASILY_COMPLETED = "easily_completed", "Easily completed"
        NEEDED_EXPLANATION = "needed_explanation", "Needed explanation"
        STRUGGLED = "struggled", "Struggled"
        NOT_COMPLETED = "not_completed", "Not completed"

    record = models.OneToOneField(
        HomeValidationRecord,
        on_delete=models.CASCADE,
        related_name="enumerator_detail",
    )

    # ── Disability ──────────────────────────────────────────────────────
    disability = models.BooleanField(null=True, blank=True)
    disability_type = models.CharField(max_length=200, blank=True)
    disability_cause = models.CharField(max_length=200, blank=True)

    # ── School history ───────────────────────────────────────────────────
    school_name = models.CharField(max_length=200, blank=True)
    school_fees = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
    fees_installments = models.CharField(max_length=32, blank=True)
    left_school_due_to_fees = models.BooleanField(null=True, blank=True)
    final_gpa = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    ever_received_scholarship = models.BooleanField(null=True, blank=True)
    scholarship_funder = models.CharField(max_length=100, blank=True)

    # ── Psychometric completion (enumerator's field observation — distinct
    # from the applicant's own PsychometricAssessment self-report) ───────
    psychometric_test_completed = models.CharField(
        max_length=24, choices=PsychometricTestCompletion.choices, blank=True
    )
    leadership_potential = models.PositiveSmallIntegerField(
        choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True
    )
    overall_vulnerability = models.PositiveSmallIntegerField(
        choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True
    )
    information_accuracy_confirmed = models.PositiveSmallIntegerField(
        choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True
    )
    general_notes = models.TextField(blank=True)

    class Meta:
        verbose_name = "Home validation — enumerator detail"
        verbose_name_plural = "Home validation — enumerator details"

    def __str__(self):  # pragma: no cover
        return f"Enumerator detail for {self.record_id}"


class HomeValidationParentDetail(models.Model):
    """Structured parent/guardian interview findings for a home validation visit.

    Old-system parity: legacy ``scholarships.ParentHomeValidationSurvey``.
    Companion to ``HomeValidationRecord`` (1:1).
    """

    record = models.OneToOneField(
        HomeValidationRecord,
        on_delete=models.CASCADE,
        related_name="parent_detail",
    )

    parent_full_name = models.CharField(max_length=200, blank=True)
    relationship_to_applicant = models.CharField(max_length=100, blank=True)

    # ── Land / asset valuation ───────────────────────────────────────────
    land_acreage = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
    land_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    number_of_rooms = models.PositiveSmallIntegerField(null=True, blank=True)
    residential_house_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    cultivated_land = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
    cultivated_land_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)

    # ── Livestock ─────────────────────────────────────────────────────────
    livestock_count = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    livestock_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    goats_and_sheep = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    goats_and_sheep_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    poultry = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    poultry_value = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)

    # ── Home assets ───────────────────────────────────────────────────────
    radios = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    televisions = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    mobile_phones = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    bicycles = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    motorcycles = models.PositiveSmallIntegerField(null=True, blank=True, default=0)
    cars = models.PositiveSmallIntegerField(null=True, blank=True, default=0)

    # ── Energy / utilities ────────────────────────────────────────────────
    cooking_energy_source = models.CharField(max_length=100, blank=True)
    source_of_light_energy = models.CharField(max_length=100, blank=True)

    # ── Household demographics ────────────────────────────────────────────
    household_members = models.PositiveSmallIntegerField(null=True, blank=True)
    school_going_children = models.PositiveSmallIntegerField(null=True, blank=True)
    children_below_16 = models.PositiveSmallIntegerField(null=True, blank=True)

    # ── Vulnerability scoring ──────────────────────────────────────────────
    vulnerability_score = models.PositiveSmallIntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    truthfulness_score = models.PositiveSmallIntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    comment = models.TextField(blank=True)

    class Meta:
        verbose_name = "Home validation — parent detail"
        verbose_name_plural = "Home validation — parent details"

    def __str__(self):  # pragma: no cover
        return f"Parent detail for {self.record_id}"


class Award(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        AGREEMENT_PENDING = "agreement_pending", "Agreement pending"
        ACTIVE = "active", "Active"
        READY_FOR_CLOSEOUT = "ready_for_closeout", "Ready for close-out"
        CLOSEOUT_IN_PROGRESS = "closeout_in_progress", "Close-out in progress"
        CLOSED = "closed", "Closed"
        CANCELLED = "cancelled", "Cancelled"

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="award",
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    awarded_at = models.DateTimeField(auto_now_add=True)
    project_end_date = models.DateField(help_text="No claims after this date")
    narrative = models.TextField(blank=True)
    activation_date = models.DateField(null=True, blank=True)
    closeout_ready_at = models.DateTimeField(null=True, blank=True)
    closed_at = models.DateTimeField(null=True, blank=True)
    cancelled_at = models.DateTimeField(null=True, blank=True)
    archived_at = models.DateTimeField(  # PRD §5.1 FRFA-CO015/016/017 — set by archive beat.
        null=True,
        blank=True,
        db_index=True,
    )
    status = models.CharField(
        max_length=32,
        choices=Status.choices,
        default=Status.ACTIVE,
        db_index=True,
    )
    # PRD §5.1 FRFA-AA025 — deferred budget: Scholarship/Fellowship/Challenge
    # call types assign the awardee-specific line-item budget after technical
    # review rather than at application. When True, the first tranche cannot be
    # released until `budget_finalized_at` is set.
    budget_deferred = models.BooleanField(default=False)
    budget_finalized_at = models.DateTimeField(null=True, blank=True)
    budget_finalized_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="awards_budget_finalized",
    )
    # PRD §5.1 FRFA-AA005 — awardee acknowledgement of the award letter.
    acknowledged_at = models.DateTimeField(null=True, blank=True)
    acknowledgement_reminders_sent = models.PositiveSmallIntegerField(default=0)
    # PRD §5.1 FRFA-AA002/AA003 — generated award letter PDF, attached to the
    # awardee's award notification email. Generated on Award.post_save when
    # the row is first created.
    letter_pdf_file = models.FileField(
        upload_to=upload_to_grants,
        null=True,
        blank=True,
    )
    letter_generated_at = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return f"Award {self.application_id}"

    def clean(self):
        """PRD cross-cutting — award currency must match the parent funding
        record's currency. Kept a hard block; FX handling is a future item."""
        super().clean()
        if self.application_id is None or not self.currency:
            return
        funding_record = getattr(self.application.call, "funding_record", None)
        if funding_record and funding_record.currency and funding_record.currency != self.currency:
            raise ValidationError(
                {
                    "currency": (
                        f"Award currency {self.currency} does not match funding record "
                        f"currency {funding_record.currency}."
                    )
                }
            )

    @property
    def is_overdue(self):
        from django.utils.timezone import now
        return self.status == self.Status.ACTIVE and self.project_end_date < now().date()


class AwardComment(models.Model):
    """Staff comment thread on an award.

    Old-system parity: legacy ``grants.GrantComment``.
    """

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="comments")
    comment = models.TextField()
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_comments",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]


class AwardStudentMembership(models.Model):
    """A student funded by a grant award, alongside (not replacing) the
    scholarship-programme ``Scholar`` model.

    Old-system parity: legacy ``grants.Studentmembership`` — a single grant
    can fund several student researchers, each with their own study year and
    support type. ``Scholar.award`` stays a 1:1 link for formal scholarship
    programmes; this is for grant-funded student researchers outside that
    programme structure.
    """

    class StudyYear(models.TextChoices):
        FIRST = "first", "First"
        SECOND = "second", "Second"
        THIRD = "third", "Third"
        FOURTH = "fourth", "Fourth"
        FIFTH = "fifth", "Fifth"
        SIXTH = "sixth", "Sixth"
        SEVENTH = "seventh", "Seventh"

    class SupportType(models.TextChoices):
        RESEARCH_ONLY = "research_only", "Research only"
        RESEARCH_AND_TUITION = "research_and_tuition", "Research and tuition"
        TUITION_ONLY = "tuition_only", "Tuition only"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="student_memberships")
    student = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="award_student_memberships",
    )
    study_year = models.CharField(max_length=16, choices=StudyYear.choices, default=StudyYear.FIRST)
    support_type = models.CharField(
        max_length=24, choices=SupportType.choices, default=SupportType.RESEARCH_AND_TUITION
    )
    enrollment_date = models.DateField(default=timezone.now)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["award", "student"],
                name="uniq_award_student_membership",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"{self.student_id} on award {self.award_id}"


class AwardLetterVersion(models.Model):
    """PRD §5.1 FRFA-AA004 — versioned snapshot of the award letter PDF.

    The current letter is also mirrored on ``Award.letter_pdf_file`` so existing
    callers (signals, emailing, chase reminders) keep working unchanged; this
    table preserves prior versions so amendment-driven regenerations don't
    lose the audit trail.
    """

    award = models.ForeignKey(
        Award,
        on_delete=models.CASCADE,
        related_name="letter_versions",
    )
    version_number = models.PositiveSmallIntegerField()
    file = models.FileField(upload_to=upload_to_grants)
    generated_at = models.DateTimeField(default=timezone.now)
    triggering_amendment = models.ForeignKey(
        "AwardAmendment",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="letter_versions_triggered",
    )

    class Meta:
        ordering = ["-version_number"]
        constraints = [
            models.UniqueConstraint(
                fields=["award", "version_number"],
                name="uniq_award_letter_version_number",
            ),
        ]

    def __str__(self) -> str:
        return f"Award {self.award_id} letter v{self.version_number}"


class AwardCloseoutRecord(models.Model):
    """Structured close-out progress (PRD grant close-out — phased checklist + financial snapshot)."""

    award = models.OneToOneField(
        Award,
        on_delete=models.CASCADE,
        related_name="closeout_record",
    )
    financial_snapshot = models.JSONField(
        default=dict,
        blank=True,
        help_text="Captured disbursement vs budget totals at last save.",
    )
    checklist = models.JSONField(
        default=list,
        blank=True,
        help_text='List of {"key": str, "label": str, "required": bool, "done": bool, "evidence_note": str}',
    )
    # PRD §5.1 FRFA-CO023 — forced close-out for awards that cannot complete
    # the standard checklist (e.g. unresponsive awardee). Requires a written
    # justification and approval by a user with the Programme Director role.
    forced_closure_justification = models.TextField(blank=True)
    forced_at = models.DateTimeField(null=True, blank=True)
    forced_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="forced_closeouts",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-updated_at"]


class InterviewSchedule(models.Model):
    class Format(models.TextChoices):
        VIRTUAL = "virtual", "Virtual"
        PHONE = "phone", "Phone"
        IN_PERSON = "in_person", "In person"

    class Status(models.TextChoices):
        SCHEDULED = "scheduled", "Scheduled"
        COMPLETED = "completed", "Completed"
        CANCELLED = "cancelled", "Cancelled"
        RESCHEDULED = "rescheduled", "Rescheduled"

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="interviews")
    scheduled_for = models.DateTimeField()
    format = models.CharField(max_length=16, choices=Format.choices, default=Format.VIRTUAL)
    venue = models.CharField(max_length=255, blank=True)
    notes = models.TextField(blank=True)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.SCHEDULED)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="interviews_created",
    )
    updated_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="interviews_updated",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-scheduled_for", "-pk"]


class InterviewOutcome(models.Model):
    class Attendance(models.TextChoices):
        ATTENDED = "attended", "Attended"
        NO_SHOW = "no_show", "No show"
        RESCHEDULED = "rescheduled", "Rescheduled"

    interview = models.OneToOneField(
        InterviewSchedule,
        on_delete=models.CASCADE,
        related_name="outcome",
    )
    attendance = models.CharField(max_length=16, choices=Attendance.choices, default=Attendance.ATTENDED)
    score = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
    recommendation_code = models.CharField(
        max_length=16,
        choices=Review.RecommendationCode.choices,
        blank=True,
    )
    summary = models.TextField(blank=True)
    recorded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="interview_outcomes_recorded",
    )
    recorded_at = models.DateTimeField(auto_now_add=True)


class VerificationRecord(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        VERIFIED = "verified", "Verified"
        FLAGGED = "flagged", "Flagged"
        FAILED = "failed", "Failed"

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="verification_records")
    verification_type = models.CharField(
        max_length=24,
        choices=GrantCall.VerificationType.choices,
        default=GrantCall.VerificationType.OTHER,
    )
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
    score = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
    summary = models.TextField(blank=True)
    findings = models.JSONField(default=dict, blank=True)
    verified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="verification_records_verified",
    )
    verified_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at", "-pk"]


class AwardAgreement(models.Model):
    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="agreements")
    version_number = models.PositiveSmallIntegerField(default=1)
    title = models.CharField(max_length=255, blank=True)
    document = models.FileField(
        upload_to=upload_to_grants,
        blank=True,
        null=True,
        validators=[_grant_doc_validator],
    )
    accepted = models.BooleanField(default=False)
    accepted_at = models.DateTimeField(null=True, blank=True)
    accepted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_agreements_accepted",
    )
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_agreements_uploaded",
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-version_number", "-pk"]
        constraints = [
            models.UniqueConstraint(fields=["award", "version_number"], name="uniq_award_agreement_version"),
        ]


class AwardTranche(models.Model):
    class Status(models.TextChoices):
        PLANNED = "planned", "Planned"
        REQUESTED = "requested", "Requested"
        APPROVED = "approved", "Approved"
        DISBURSED = "disbursed", "Disbursed"
        HELD = "held", "Held"
        CANCELLED = "cancelled", "Cancelled"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="tranches")
    milestone = models.ForeignKey(
        "rims_projects.Milestone",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_tranches",
    )
    label = models.CharField(max_length=128)
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    due_on = models.DateField(null=True, blank=True)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.PLANNED)
    payment_reference = models.CharField(max_length=96, blank=True)
    disbursed_at = models.DateTimeField(null=True, blank=True)
    # PRD §5.1 FRFA-AA016 — cascade-cancellation timestamp so audit can pinpoint
    # which cancellation request retired a planned tranche.
    cancelled_at = models.DateTimeField(null=True, blank=True)
    sort_order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["sort_order", "pk"]

    def clean(self):
        """PRD §5.1 FRFA-AA011 — sum of tranche amounts for a given award
        must equal the award amount. Only enforced once the tranche has an
        amount set."""
        super().clean()
        if self.amount is None or self.award_id is None:
            return
        existing = list(
            AwardTranche.objects.filter(award_id=self.award_id).exclude(pk=self.pk).values_list("amount", flat=True)
        )
        total = sum(existing, Decimal("0")) + self.amount
        # Guard against a sibling tranche causing total > award amount. We
        # allow partial configurations (total < amount) — that's a work in
        # progress, not invalid yet.
        if total > self.award.amount:
            raise ValidationError(
                {
                    "amount": (
                        f"Sum of tranche amounts ({total}) would exceed award amount "
                        f"({self.award.amount})."
                    )
                }
            )


class TrancheBudgetAllocation(models.Model):
    """PRD §5.1 FRFA-AA012 — bridge table linking tranches to budget lines.

    A single tranche can draw from multiple budget lines (e.g. personnel +
    travel both fund a milestone payment), and a single budget line can fund
    multiple tranches over time. This bridge captures the per-line amount so
    ``reconcile_tranche`` can verify the tranche total matches its allocations
    and the budget line ceiling is not exceeded.
    """

    tranche = models.ForeignKey(
        AwardTranche,
        on_delete=models.CASCADE,
        related_name="budget_allocations",
    )
    budget_line = models.ForeignKey(
        "rims_finance.BudgetLine",
        on_delete=models.CASCADE,
        related_name="tranche_allocations",
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["tranche_id", "pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["tranche", "budget_line"],
                name="uniq_tranche_budget_line_allocation",
            ),
        ]

    def __str__(self) -> str:
        return f"Tranche {self.tranche_id} ← BudgetLine {self.budget_line_id}: {self.amount}"


class AwardAmendment(models.Model):
    """PRD §5.1 FRFA-AA021–023 — award amendment request with approval FSM
    and versioned before/after snapshot (`previous_terms` / `new_terms`)."""

    class Status(models.TextChoices):
        REQUESTED = "requested", "Requested"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="amendments")
    amendment_number = models.PositiveSmallIntegerField(default=1)
    status = FSMField(
        max_length=16,
        choices=Status.choices,
        default=Status.REQUESTED,
        protected=True,
        db_index=True,
    )
    change_summary = models.TextField()
    justification = models.TextField(blank=True)
    previous_terms = models.JSONField(default=dict, blank=True)
    new_terms = models.JSONField(default=dict, blank=True)
    approver = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_amendments_approved",
    )
    rejection_reason = models.TextField(blank=True)
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_amendments_requested",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    decided_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-amendment_number", "-pk"]
        constraints = [
            models.UniqueConstraint(fields=["award", "amendment_number"], name="uniq_award_amendment_number"),
        ]

    @transition(field=status, source=Status.REQUESTED, target=Status.APPROVED)
    def approve(self):
        self.decided_at = timezone.now()

    @transition(field=status, source=Status.REQUESTED, target=Status.REJECTED)
    def reject(self):
        self.decided_at = timezone.now()

    @transition(field=status, source=Status.REJECTED, target=Status.REQUESTED)
    def resubmit(self):
        """PRD §5.1 FRFA-AA021–023 — revise + resubmit a rejected amendment.

        P0 (assessment §3 #4) — keep the same amendment_number so reviewers
        see the iteration on a single row. The prior ``rejection_reason`` and
        ``decided_at`` are cleared (the AuditLog row preserves the audit
        history).
        """
        self.decided_at = None
        self.rejection_reason = ""
        self.approver = None


class AwardCancellationRequest(models.Model):
    """PRD §5.1 FRFA-AA016/AA017 — cancellation request with cascade record.

    Status walk: REQUESTED → DIRECTOR_REVIEW → APPROVED/REJECTED, or
    REQUESTED → WITHDRAWN. Approval triggers the cascade service that cancels
    planned tranches, releases the uncommitted balance, and auto-creates
    RecoveryRequest stubs for already-disbursed tranches.
    """

    class Status(models.TextChoices):
        REQUESTED = "requested", "Requested"
        DIRECTOR_REVIEW = "director_review", "Awaiting director review"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"
        WITHDRAWN = "withdrawn", "Withdrawn"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="cancellation_requests")
    status = models.CharField(max_length=24, choices=Status.choices, default=Status.REQUESTED)
    reason = models.TextField()
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_cancellations_requested",
    )
    decided_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_cancellations_decided",
    )
    decision_note = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    decided_at = models.DateTimeField(null=True, blank=True)
    # PRD §5.1 FRFA-AA017 — cascade summary captured at approval-time so the
    # audit trail survives later edits to tranches / funding.
    cascade_summary = models.JSONField(default=dict, blank=True)

    class Meta:
        ordering = ["-created_at"]


class AwardCohort(models.Model):
    """PRD §5.1 FRFA-AA023-030 — cohort of awardees under a multi-year award.

    Cohorts model the "add a second cohort of fellows mid-award" scenario for
    Scholarship / Fellowship / Challenge call types. Cohort #1 is auto-created
    when the award is activated; subsequent cohorts come in through
    ``CohortAddendum`` rows that the Programme Director approves.
    """

    class Status(models.TextChoices):
        PLANNED = "planned", "Planned"
        ACTIVE = "active", "Active"
        COMPLETED = "completed", "Completed"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="cohorts")
    cohort_number = models.PositiveSmallIntegerField()
    label = models.CharField(max_length=128)
    start_date = models.DateField(null=True, blank=True)
    end_date = models.DateField(null=True, blank=True)
    target_participants = models.PositiveIntegerField(default=0)
    budget_allocation = models.DecimalField(
        max_digits=14, decimal_places=2, default=Decimal("0")
    )
    status = models.CharField(
        max_length=16, choices=Status.choices, default=Status.PLANNED
    )
    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="award_cohorts_created",
    )

    class Meta:
        ordering = ["award_id", "cohort_number"]
        constraints = [
            models.UniqueConstraint(
                fields=["award", "cohort_number"],
                name="uniq_award_cohort_number",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.label} (Award {self.award_id} cohort #{self.cohort_number})"


class CohortAddendum(models.Model):
    """PRD §5.1 FRFA-AA023-030 — late-cohort addendum to a multi-year award.

    Workflow: REQUESTED → DIRECTOR_REVIEW → APPROVED / REJECTED, or
    REQUESTED → WITHDRAWN. Approval creates an ``AwardCohort`` row from
    ``new_terms`` and regenerates the award letter (FRFA-AA004 hook).
    """

    class Status(models.TextChoices):
        REQUESTED = "requested", "Requested"
        DIRECTOR_REVIEW = "director_review", "Awaiting director review"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"
        WITHDRAWN = "withdrawn", "Withdrawn"

    award = models.ForeignKey(
        Award, on_delete=models.CASCADE, related_name="cohort_addenda"
    )
    cohort = models.ForeignKey(
        AwardCohort,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="addenda",
    )
    status = models.CharField(
        max_length=24,
        choices=Status.choices,
        default=Status.REQUESTED,
        db_index=True,
    )
    reason = models.TextField()
    previous_terms = models.JSONField(default=dict, blank=True)
    new_terms = models.JSONField(default=dict, blank=True)
    decision_notes = models.TextField(blank=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="cohort_addenda_requested",
    )
    decided_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="cohort_addenda_decided",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    decided_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self) -> str:
        return f"Cohort addendum #{self.pk} on award {self.award_id}"


class FinalNarrativeReport(models.Model):
    """PRD §5.1 FRFA-CO005–010 — final narrative report with review FSM."""

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    closeout = models.ForeignKey(AwardCloseoutRecord, on_delete=models.CASCADE, related_name="narrative_reports")
    report = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    notes = models.TextField(blank=True)
    review_comment = models.TextField(blank=True)
    response_note = models.TextField(blank=True)  # PRD §5.1 FRFA-CO006 — awardee reply on resubmit.
    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.SUBMITTED,
        protected=True,
        db_index=True,
    )
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="final_narrative_reports_submitted",
    )
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="final_narrative_reports_reviewed",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    reviewed_at = models.DateTimeField(null=True, blank=True)

    @transition(field=status, source=Status.SUBMITTED, target=Status.ACCEPTED)
    def accept(self):
        self.reviewed_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.REVISION_REQUIRED)
    def request_revision(self):
        self.reviewed_at = timezone.now()

    @transition(field=status, source=Status.REVISION_REQUIRED, target=Status.SUBMITTED)
    def resubmit(self):
        self.reviewed_at = None


class FinalFinancialReport(models.Model):
    """PRD §5.1 FRFA-CO007–009 — final financial report with review FSM."""

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        QUERIES_RAISED = "queries_raised", "Queries raised"
        REVISION_REQUIRED = "revision_required", "Revision required"

    closeout = models.ForeignKey(AwardCloseoutRecord, on_delete=models.CASCADE, related_name="financial_reports")
    report = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    notes = models.TextField(blank=True)
    queries = models.TextField(blank=True)
    response_note = models.TextField(blank=True)  # PRD §5.1 FRFA-CO006 — awardee reply on resubmit.
    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.SUBMITTED,
        protected=True,
        db_index=True,
    )
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="final_financial_reports_submitted",
    )
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="final_financial_reports_reviewed",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    reviewed_at = models.DateTimeField(null=True, blank=True)

    @transition(field=status, source=Status.SUBMITTED, target=Status.ACCEPTED)
    def accept(self):
        self.reviewed_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.QUERIES_RAISED)
    def raise_queries(self):
        self.reviewed_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.REVISION_REQUIRED)
    def request_revision(self):
        self.reviewed_at = timezone.now()

    @transition(
        field=status,
        source=[Status.QUERIES_RAISED, Status.REVISION_REQUIRED],
        target=Status.SUBMITTED,
    )
    def resubmit(self):
        self.reviewed_at = None


class AssetVerification(models.Model):
    class Status(models.TextChoices):
        CONFIRMED = "confirmed", "Confirmed"
        TRANSFERRED = "transferred", "Transferred"
        DISPOSED = "disposed", "Disposed"
        UNACCOUNTED = "unaccounted", "Unaccounted"

    closeout = models.ForeignKey(AwardCloseoutRecord, on_delete=models.CASCADE, related_name="asset_verifications")
    asset_name = models.CharField(max_length=255)
    status = models.CharField(max_length=16, choices=Status.choices)
    notes = models.TextField(blank=True)
    evidence = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    verified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="asset_verifications_recorded",
    )
    created_at = models.DateTimeField(auto_now_add=True)


class RecoveryRequest(models.Model):
    """PRD §5.1 FRFA-CO + FRFA-AA017 — recovery of disbursed funds.

    Originally tied only to close-out records; the cancellation cascade also
    auto-creates these (linked via ``award`` + ``cancellation`` FKs) for
    already-disbursed tranches on a cancelled award.
    """

    closeout = models.ForeignKey(
        AwardCloseoutRecord,
        on_delete=models.CASCADE,
        related_name="recovery_requests",
        null=True,
        blank=True,
    )
    award = models.ForeignKey(
        Award,
        on_delete=models.CASCADE,
        related_name="recovery_requests",
        null=True,
        blank=True,
    )
    cancellation = models.ForeignKey(
        AwardCancellationRequest,
        on_delete=models.SET_NULL,
        related_name="recovery_requests",
        null=True,
        blank=True,
    )
    tranche = models.ForeignKey(
        AwardTranche,
        on_delete=models.SET_NULL,
        related_name="recovery_requests",
        null=True,
        blank=True,
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    reason = models.TextField(blank=True)
    payment_reference = models.CharField(max_length=96, blank=True)
    received_on = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)


class RecoveryWaiver(models.Model):
    closeout = models.ForeignKey(AwardCloseoutRecord, on_delete=models.CASCADE, related_name="recovery_waivers")
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    justification = models.TextField()
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="recovery_waivers_approved",
    )
    approved_at = models.DateTimeField(auto_now_add=True)


class FundingRecordCloseout(models.Model):
    class Status(models.TextChoices):
        READY = "ready", "Ready"
        SUBMITTED = "submitted", "Submitted"
        CLOSED = "closed", "Closed"

    funding_record = models.OneToOneField(
        FundingRecord,
        on_delete=models.CASCADE,
        related_name="closeout_record",
    )
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.READY)
    summary = models.JSONField(default=dict, blank=True)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_closeouts_submitted",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    closed_at = models.DateTimeField(null=True, blank=True)


class FundingRecordBudgetRevision(models.Model):
    """PRD §5.1 FRFA-GPS027 — request to revise budget / dates / reporting
    schedule on an active FundingRecord. Parallels :class:`AwardAmendment`
    but scoped to the parent funding record (not individual awards)."""

    class Status(models.TextChoices):
        REQUESTED = "requested", "Requested"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"

    funding_record = models.ForeignKey(
        FundingRecord,
        on_delete=models.CASCADE,
        related_name="budget_revisions",
    )
    revision_number = models.PositiveSmallIntegerField(default=1)
    status = FSMField(
        max_length=16,
        choices=Status.choices,
        default=Status.REQUESTED,
        protected=True,
        db_index=True,
    )
    change_summary = models.TextField()
    justification = models.TextField(blank=True)
    previous_terms = models.JSONField(default=dict, blank=True)
    new_terms = models.JSONField(default=dict, blank=True)
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_revisions_requested",
    )
    approver = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_revisions_approved",
    )
    rejection_reason = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    decided_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-revision_number", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["funding_record", "revision_number"],
                name="uniq_fr_budget_revision_number",
            ),
        ]

    @transition(field=status, source=Status.REQUESTED, target=Status.APPROVED)
    def approve(self):
        self.decided_at = timezone.now()

    @transition(field=status, source=Status.REQUESTED, target=Status.REJECTED)
    def reject(self):
        self.decided_at = timezone.now()


class AcademicProgressReport(models.Model):
    """PRD §5.1 Table 14 — periodic academic-cycle progress report for a
    scholarship-funded award.

    Sits between the standard milestone reporting (geared at grant projects)
    and the close-out final reports. One row per academic period (semester
    or year) with GPA + supervisor sign-off. Linked to an Award rather than
    to the legacy Scholar model, which keeps the data integrated with the
    unified award lifecycle even if the Scholar / Stipend tables are
    retained for legacy compatibility.
    """

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="academic_progress_reports")
    period_label = models.CharField(
        max_length=64,
        help_text="e.g. 'Sem 1 2026' or 'AY 2026/27'",
    )
    gpa = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)
    supervisor_name = models.CharField(max_length=255, blank=True)
    supervisor_signoff_at = models.DateTimeField(null=True, blank=True)
    summary = models.TextField(blank=True)
    transcript = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    status = models.CharField(max_length=24, choices=Status.choices, default=Status.SUBMITTED)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="academic_progress_reports_submitted",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="academic_progress_reports_reviewed",
    )
    reviewed_at = models.DateTimeField(null=True, blank=True)
    review_comment = models.TextField(blank=True)

    class Meta:
        ordering = ["-submitted_at", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["award", "period_label"],
                name="uniq_academic_progress_per_award_period",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"{self.award_id} · {self.period_label}"


class FellowshipProgressReport(models.Model):
    """Periodic placement-progress report for a fellowship-funded award.

    Closes the SRS gap where Fellowships fall back to the generic
    milestone/tranche mechanism with no fellowship-specific reporting
    fields, unlike scholarships' AcademicProgressReport.
    """

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="fellowship_progress_reports")
    period_label = models.CharField(max_length=64, help_text="e.g. 'Month 3 of placement'")
    host_institution_feedback = models.TextField(blank=True)
    activities_completed = models.TextField(blank=True)
    skills_gained = models.TextField(blank=True)
    challenges_faced = models.TextField(blank=True)
    supervisor_signoff_by = models.CharField(max_length=255, blank=True)
    supervisor_signoff_at = models.DateTimeField(null=True, blank=True)
    status = models.CharField(max_length=24, choices=Status.choices, default=Status.SUBMITTED)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="fellowship_progress_reports_submitted",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="fellowship_progress_reports_reviewed",
    )
    reviewed_at = models.DateTimeField(null=True, blank=True)
    review_comment = models.TextField(blank=True)

    class Meta:
        ordering = ["-submitted_at", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["award", "period_label"],
                name="uniq_fellowship_progress_per_award_period",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"{self.award_id} · {self.period_label}"


class ChallengeMilestoneReport(models.Model):
    """Periodic innovation-milestone report for a challenge-funded award.

    Closes the SRS gap where Challenges fall back to the generic
    milestone/tranche mechanism with no innovation-specific reporting
    fields, unlike scholarships' AcademicProgressReport.
    """

    class Status(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="challenge_milestone_reports")
    period_label = models.CharField(max_length=64, help_text="e.g. 'Milestone 2'")
    innovation_milestone_reached = models.CharField(max_length=255, blank=True)
    prototype_status = models.CharField(max_length=24, choices=PrototypeStatus.choices, blank=True)
    evidence_upload = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    next_steps = models.TextField(blank=True)
    status = models.CharField(max_length=24, choices=Status.choices, default=Status.SUBMITTED)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="challenge_milestone_reports_submitted",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="challenge_milestone_reports_reviewed",
    )
    reviewed_at = models.DateTimeField(null=True, blank=True)
    review_comment = models.TextField(blank=True)

    class Meta:
        ordering = ["-submitted_at", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["award", "period_label"],
                name="uniq_challenge_milestone_per_award_period",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"{self.award_id} · {self.period_label}"


class FundingRecordVersion(models.Model):
    """PRD §5.1 FRFA-GPS026 — version history for funding records.

    A new row is written by ``services.resubmit_funding_record`` each time the
    record is resubmitted after a rejection (or any other governed-revision
    event). The snapshot is a JSON serialisation of the funding record's
    state at the time so future reviewers can diff against the live row.
    """

    funding_record = models.ForeignKey(
        FundingRecord, on_delete=models.CASCADE, related_name="versions"
    )
    version_number = models.PositiveIntegerField()
    snapshot = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_record_versions_created",
    )

    class Meta:
        ordering = ["-version_number", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["funding_record", "version_number"],
                name="uniq_funding_record_version",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"v{self.version_number} of {self.funding_record_id}"


class GrantCallVersion(models.Model):
    """PRD §5.1 FRFA-CS033 — version history for grant calls.

    A new row is written by ``services.snapshot_call_version`` on every
    governed mid-flight edit (CS028 active-call edit, CS029 deadline
    extension). The snapshot is a JSON serialisation of the call state at the
    time so future reviewers can diff against the live row.
    """

    call = models.ForeignKey(GrantCall, on_delete=models.CASCADE, related_name="versions")
    version_number = models.PositiveIntegerField()
    snapshot = models.JSONField(default=dict)
    change_summary = models.CharField(
        max_length=255,
        blank=True,
        help_text="Short label describing the change (e.g. 'Deadline extended', 'Description revised').",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="grant_call_versions_created",
    )

    class Meta:
        ordering = ["-version_number", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["call", "version_number"],
                name="uniq_grant_call_version",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"v{self.version_number} of {self.call_id}"


class MilestoneEvidence(models.Model):
    """PRD §5.1 FRFA-AA015 / AA016 — per-tranche evidence the awardee uploads
    to support a milestone payment release.

    Linked to AwardTranche so the Grants Manager review status (and any
    payment-release follow-up) is tied directly to the financial entry that
    the evidence justifies."""

    class ReviewStatus(models.TextChoices):
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    tranche = models.ForeignKey(
        AwardTranche, on_delete=models.CASCADE, related_name="evidence"
    )
    file = models.FileField(upload_to=upload_to_grants, validators=[_grant_doc_validator])
    summary = models.TextField(blank=True)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="milestone_evidence_submitted",
    )
    submitted_at = models.DateTimeField(auto_now_add=True)
    review_status = models.CharField(
        max_length=24,
        choices=ReviewStatus.choices,
        default=ReviewStatus.SUBMITTED,
    )
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="milestone_evidence_reviewed",
    )
    reviewed_at = models.DateTimeField(null=True, blank=True)
    review_comment = models.TextField(blank=True)

    class Meta:
        ordering = ["-submitted_at", "-pk"]


class ReportingCalendarEntry(models.Model):
    """PRD §5.1 FRFA-GPS015 — generated reporting calendar entries for a
    funding record. Produced by ``services.generate_reporting_calendar``
    when the record is approved / activated; each entry represents a
    scheduled checkpoint (financial / narrative)."""

    class Kind(models.TextChoices):
        FINANCIAL = "financial", "Financial report"
        NARRATIVE = "narrative", "Narrative report"

    funding_record = models.ForeignKey(
        FundingRecord, on_delete=models.CASCADE, related_name="reporting_calendar"
    )
    kind = models.CharField(max_length=16, choices=Kind.choices)
    due_on = models.DateField()
    label = models.CharField(max_length=128, blank=True)
    submitted_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["due_on", "kind"]
        constraints = [
            models.UniqueConstraint(
                fields=["funding_record", "kind", "due_on"],
                name="uniq_reporting_calendar_entry",
            ),
        ]


class ImplementationPlan(models.Model):
    """SRS §4.1.7 "Create Implementation Plan" — awardee's implementation
    plan for an active award: objective, outcomes, outputs, activities,
    risks, and assumptions, subject to Programme Officer approval.

    On approval, :func:`services.generate_results_framework` derives a real
    ``mel_indicators.LogFrame`` (Impact→Outcome→Output→Activity hierarchy)
    from this plan — no M&E machinery is duplicated here; this model only
    captures the plan and its approval gate, and links to the generated
    LogFrame via ``results_framework``.
    """

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        APPROVED = "approved", "Approved"
        REVISION_REQUIRED = "revision_required", "Revision required"

    award = models.OneToOneField(Award, on_delete=models.CASCADE, related_name="implementation_plan")
    objective = models.TextField(help_text="Overall project/implementation objective — maps to the Impact row.")
    assumptions = models.TextField(blank=True, help_text="Key assumptions necessary for successful implementation.")
    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    results_framework = models.ForeignKey(
        "mel_indicators.LogFrame",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
        help_text="Populated by generate_results_framework() once this plan is approved.",
    )
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="implementation_plans_submitted",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="implementation_plans_approved",
    )
    approved_at = models.DateTimeField(null=True, blank=True)
    revision_comment = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):  # pragma: no cover
        return f"Implementation plan for award {self.award_id}"

    @transition(field=status, source=Status.DRAFT, target=Status.SUBMITTED)
    def submit(self):
        self.submitted_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.APPROVED)
    def approve(self):
        self.approved_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.REVISION_REQUIRED)
    def request_revision(self):
        self.approved_at = None

    @transition(field=status, source=Status.REVISION_REQUIRED, target=Status.SUBMITTED)
    def resubmit(self):
        self.submitted_at = timezone.now()


class ImplementationPlanOutcome(models.Model):
    plan = models.ForeignKey(ImplementationPlan, on_delete=models.CASCADE, related_name="outcomes")
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["order", "pk"]

    def __str__(self):  # pragma: no cover
        return self.title


class ImplementationPlanOutput(models.Model):
    outcome = models.ForeignKey(ImplementationPlanOutcome, on_delete=models.CASCADE, related_name="outputs")
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    order = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["order", "pk"]

    def __str__(self):  # pragma: no cover
        return self.title


class ImplementationPlanIndicator(models.Model):
    """Performance indicator attached to an outcome or an output (exactly
    one) — SRS: "The awardee defines one or more performance indicators for
    each outcome and output."""

    class MeansOfVerification(models.TextChoices):
        REPORT = "report", "Report"
        PUBLICATION = "publication", "Publication"
        CERTIFICATE = "certificate", "Certificate"
        DATASET = "dataset", "Dataset"
        ATTENDANCE_REGISTER = "attendance_register", "Attendance register"
        MONITORING_REPORT = "monitoring_report", "Monitoring report"
        OTHER = "other", "Other"

    class ReportingFrequency(models.TextChoices):
        """Value strings intentionally match ``mel_indicators.IndicatorFrequency``
        so generate_results_framework() can pass this value straight through
        without a mapping table."""

        DAILY = "daily", "Daily"
        WEEKLY = "weekly", "Weekly"
        MONTHLY = "monthly", "Monthly"
        QUARTERLY = "quarterly", "Quarterly"
        SEMI_ANNUAL = "semi_annual", "Semi-annual"
        ANNUAL = "annual", "Annual"
        AD_HOC = "ad_hoc", "Ad hoc"

    outcome = models.ForeignKey(
        ImplementationPlanOutcome, on_delete=models.CASCADE, null=True, blank=True, related_name="indicators"
    )
    output = models.ForeignKey(
        ImplementationPlanOutput, on_delete=models.CASCADE, null=True, blank=True, related_name="indicators"
    )
    name = models.CharField(max_length=255)
    baseline_value = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
    target_value = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
    means_of_verification = models.CharField(max_length=24, choices=MeansOfVerification.choices)
    reporting_frequency = models.CharField(max_length=16, choices=ReportingFrequency.choices)
    mel_indicator = models.ForeignKey(
        "mel_indicators.Indicator",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
        help_text="Populated by generate_results_framework() once the plan is approved.",
    )

    def __str__(self):  # pragma: no cover
        return self.name

    def clean(self):
        super().clean()
        if bool(self.outcome_id) == bool(self.output_id):
            raise ValidationError("An indicator must attach to exactly one of outcome or output.")


class ImplementationPlanActivity(models.Model):
    class Status(models.TextChoices):
        NOT_STARTED = "not_started", "Not started"
        IN_PROGRESS = "in_progress", "In progress"
        COMPLETED = "completed", "Completed"

    output = models.ForeignKey(ImplementationPlanOutput, on_delete=models.CASCADE, related_name="activities")
    parent = models.ForeignKey(
        "self", on_delete=models.CASCADE, null=True, blank=True, related_name="sub_activities",
        help_text="Set for sub-activities.",
    )
    depends_on = models.ManyToManyField(
        "self", symmetrical=False, blank=True, related_name="blocks",
        help_text="Activities that must complete before this one can start.",
    )
    title = models.CharField(max_length=255)
    planned_start = models.DateField(null=True, blank=True)
    planned_end = models.DateField(null=True, blank=True)
    responsible_person = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+",
    )
    risks = models.TextField(blank=True)
    mitigation_measures = models.TextField(blank=True)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.NOT_STARTED)
    percent_complete = models.PositiveSmallIntegerField(
        default=0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
    )
    mel_logframe_row = models.ForeignKey(
        "mel_indicators.LogFrameRow",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
        help_text="Populated by generate_results_framework() once the plan is approved.",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name_plural = "Implementation plan activities"

    def __str__(self):  # pragma: no cover
        return self.title


class ImplementationPlanEvidence(models.Model):
    activity = models.ForeignKey(ImplementationPlanActivity, on_delete=models.CASCADE, related_name="evidence")
    file = models.FileField(upload_to=upload_to_grants, validators=[_grant_doc_validator])
    description = models.CharField(max_length=255, blank=True)
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+",
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "Implementation plan evidence"


class MonitoringVisit(models.Model):
    """SRS §4.1.7 "Monitoring Visit (Optional)". Old-system-and-SRS gap: no
    prior model anywhere in the codebase captured visit scheduling,
    findings, evidence, or GPS coordinates."""

    class VisitType(models.TextChoices):
        PHYSICAL = "physical", "Physical"
        VIRTUAL = "virtual", "Virtual"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="monitoring_visits")
    activity = models.ForeignKey(
        ImplementationPlanActivity, on_delete=models.SET_NULL, null=True, blank=True,
        related_name="monitoring_visits",
    )
    initiated_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+",
    )
    visit_type = models.CharField(max_length=16, choices=VisitType.choices, default=VisitType.PHYSICAL)
    scheduled_for = models.DateTimeField()
    participants = models.TextField(blank=True, help_text="Names/roles of expected participants.")
    agenda = models.TextField(blank=True)
    findings = models.TextField(blank=True)
    strengths = models.TextField(blank=True)
    gaps = models.TextField(blank=True)
    risks_identified = models.TextField(blank=True)
    recommendations = models.TextField(blank=True)
    latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
    longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
    conducted_at = models.DateTimeField(null=True, blank=True)
    locked_at = models.DateTimeField(
        null=True, blank=True,
        help_text="Set once findings are submitted — further edits are refused by the service layer.",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-scheduled_for"]

    def __str__(self):  # pragma: no cover
        return f"Monitoring visit for award {self.award_id} on {self.scheduled_for:%Y-%m-%d}"

    @property
    def is_locked(self):
        return self.locked_at is not None


class MonitoringVisitEvidence(models.Model):
    visit = models.ForeignKey(MonitoringVisit, on_delete=models.CASCADE, related_name="evidence")
    file = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    caption = models.CharField(max_length=255, blank=True)


class BeneficiaryGroup(models.Model):
    """Lookup list of beneficiary categories for periodic narrative reports
    (old-system parity: legacy ``grants_reports.Beneficiary``)."""

    name = models.CharField(max_length=120, unique=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class PeriodicNarrativeReport(models.Model):
    """Structured mid-project PI narrative report for an award.

    Old-system parity: legacy ``grants_reports.Month12Report`` (and the
    repeated per-period Month*Report models it duplicated). One row per
    reporting period for the award's lifetime — distinct from
    ``FinalNarrativeReport``, which is close-out only, and from
    ``ReportingCalendarEntry``, which only tracks the due date/kind.
    """

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        ACCEPTED = "accepted", "Accepted"
        REVISION_REQUIRED = "revision_required", "Revision required"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="periodic_narrative_reports")
    reporting_calendar_entry = models.ForeignKey(
        ReportingCalendarEntry,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="periodic_narrative_reports",
        help_text="Optional link back to the scheduled due-date entry this report satisfies.",
    )
    period_label = models.CharField(max_length=64, help_text="e.g. 'Month 12' or 'Year 1'")

    male_participants = models.PositiveIntegerField(null=True, blank=True)
    female_participants = models.PositiveIntegerField(null=True, blank=True)
    direct_beneficiaries = models.PositiveIntegerField(null=True, blank=True)
    indirect_beneficiaries = models.PositiveIntegerField(null=True, blank=True)
    beneficiary_groups = models.ManyToManyField(
        BeneficiaryGroup, blank=True, related_name="periodic_narrative_reports"
    )
    field_days_organized = models.PositiveIntegerField(null=True, blank=True)

    summary_progress = models.TextField(blank=True)
    linkages_established = models.TextField(blank=True)
    uptake_pathway = models.TextField(blank=True)
    unexpected_spillover = models.TextField(blank=True)
    problems_and_challenges = models.TextField(blank=True)
    problems_and_challenges_solution = models.TextField(blank=True)

    no_cost_extension_required = models.BooleanField(default=False)
    no_cost_extension_explanation = models.TextField(blank=True)
    on_schedule = models.BooleanField(default=True)
    not_on_schedule_explanation = models.TextField(blank=True)

    flyers_brochures_produced = models.BooleanField(default=False)
    flyers_brochures_upload = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    policy_briefs_produced = models.BooleanField(default=False)
    policy_briefs_upload = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    audited_financial_report = models.FileField(upload_to=upload_to_grants, blank=True, null=True)

    status = FSMField(
        max_length=24,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="periodic_narrative_reports_submitted",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    accepted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="periodic_narrative_reports_accepted",
    )
    accepted_at = models.DateTimeField(null=True, blank=True)
    review_comment = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at", "-pk"]

    def __str__(self):  # pragma: no cover
        return f"{self.award_id} · {self.period_label}"

    @transition(field=status, source=Status.DRAFT, target=Status.SUBMITTED)
    def submit(self):
        self.submitted_at = timezone.now()
        if self.reporting_calendar_entry_id and not self.reporting_calendar_entry.submitted_at:
            self.reporting_calendar_entry.submitted_at = self.submitted_at
            self.reporting_calendar_entry.save(update_fields=["submitted_at"])

    @transition(field=status, source=Status.SUBMITTED, target=Status.ACCEPTED)
    def accept(self):
        self.accepted_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.REVISION_REQUIRED)
    def request_revision(self):
        self.accepted_at = None

    @transition(field=status, source=Status.REVISION_REQUIRED, target=Status.SUBMITTED)
    def resubmit(self):
        self.submitted_at = timezone.now()


class ReportPicture(models.Model):
    """Old-system parity: legacy per-period ``RelevantPicturesNN`` models."""

    report = models.ForeignKey(PeriodicNarrativeReport, on_delete=models.CASCADE, related_name="pictures")
    image = models.ImageField(upload_to=upload_to_grants)
    caption = models.CharField(max_length=255, blank=True)


class ReportLinkage(models.Model):
    """Old-system parity: legacy per-period ``LinkageNN`` models."""

    report = models.ForeignKey(PeriodicNarrativeReport, on_delete=models.CASCADE, related_name="linkages")
    organisation = models.CharField(max_length=255)
    partner_description = models.TextField(blank=True)
    linkage_document = models.FileField(upload_to=upload_to_grants, blank=True, null=True)


class ReportComment(models.Model):
    """Old-system parity: legacy per-period ``MonthNNComment`` models."""

    report = models.ForeignKey(PeriodicNarrativeReport, on_delete=models.CASCADE, related_name="comments")
    comment = models.TextField()
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="periodic_narrative_report_comments",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]


class ScholarshipApplicationProfile(models.Model):
    """Extended profile for scholarship-type applications (Mastercard/TAGDev, GTA, etc.).

    Attached 1:1 to an Application whose call.call_type == 'scholarship'.
    Stores student-specific eligibility and socioeconomic screening data that
    does not belong on the generic Application model.

    Socioeconomic fields (household, livestock, assets) are used by the
    programme officer during home-validation screening — they are NOT used
    in the core grant review workflow.
    """

    class HomeValidationStatus(models.TextChoices):
        NOT_REQUIRED = "not_required", "Not required"
        PENDING = "pending", "Pending"
        SCHEDULED = "scheduled", "Scheduled"
        COMPLETED = "completed", "Completed"
        FAILED = "failed", "Failed"

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="scholarship_profile",
    )

    # ── Basic personal info (captured at application time) ───────────────
    date_of_birth = models.DateField(null=True, blank=True)
    gender = models.CharField(max_length=16, blank=True)
    country_of_birth = models.CharField(max_length=3, blank=True, help_text="ISO 3166-1 alpha-3")
    country_of_residence = models.CharField(max_length=3, blank=True, help_text="ISO 3166-1 alpha-3")
    country_of_origin = models.CharField(max_length=3, blank=True)
    is_refugee = models.BooleanField(null=True, blank=True)

    # ── Academic ─────────────────────────────────────────────────────────
    degree_programme = models.CharField(max_length=100, blank=True)
    gpa = models.FloatField(null=True, blank=True)
    grading_criteria = models.CharField(max_length=64, blank=True)
    english_in_high_school = models.BooleanField(null=True, blank=True)
    applied_to_university = models.BooleanField(null=True, blank=True)
    postgraduate_student = models.BooleanField(null=True, blank=True)

    # ── Household / socioeconomic screening ──────────────────────────────
    people_in_house = models.PositiveSmallIntegerField(null=True, blank=True)
    rooms_in_house = models.PositiveSmallIntegerField(null=True, blank=True)
    how_many_share_toilet = models.PositiveSmallIntegerField(null=True, blank=True)
    electricity = models.BooleanField(null=True, blank=True)
    solar_energy = models.BooleanField(null=True, blank=True)
    water_source = models.CharField(max_length=64, blank=True)
    toilet_type = models.CharField(max_length=32, blank=True)

    # ── Housing ──────────────────────────────────────────────────────────
    village_of_birth = models.CharField(max_length=100, blank=True)
    district_of_birth = models.CharField(max_length=100, blank=True)
    village_of_residence = models.CharField(max_length=100, blank=True)
    district_of_residence = models.CharField(max_length=100, blank=True)
    nearest_major_road = models.CharField(max_length=100, blank=True)
    nearest_trading_centre = models.CharField(max_length=100, blank=True)
    distance_to_the_source = models.PositiveSmallIntegerField(null=True, blank=True)

    # ── Guardian / family ────────────────────────────────────────────────
    name_of_guardian_or_spouse = models.CharField(max_length=150, blank=True)
    guardian_relationship = models.CharField(max_length=100, blank=True)
    guardian_occupation = models.CharField(max_length=100, blank=True)
    guardian_or_spouse_phone = models.CharField(max_length=32, blank=True)
    number_of_siblings = models.PositiveSmallIntegerField(null=True, blank=True)

    # ── Income sources (up to 4 from legacy) ─────────────────────────────
    income_source_1 = models.CharField(max_length=150, blank=True)
    income_source_2 = models.CharField(max_length=150, blank=True)
    income_source_3 = models.CharField(max_length=150, blank=True)
    income_source_4 = models.CharField(max_length=150, blank=True)

    # ── Livestock / assets ────────────────────────────────────────────────
    own_livestock = models.BooleanField(null=True, blank=True)
    number_of_cattle = models.PositiveSmallIntegerField(null=True, blank=True)
    number_of_goats = models.PositiveSmallIntegerField(null=True, blank=True)
    number_of_sheep = models.PositiveSmallIntegerField(null=True, blank=True)
    number_of_chickens = models.PositiveSmallIntegerField(null=True, blank=True)
    number_of_camels = models.PositiveSmallIntegerField(null=True, blank=True)
    number_of_donkeys = models.PositiveSmallIntegerField(null=True, blank=True)

    # ── Health / disability ───────────────────────────────────────────────
    have_physical_disability = models.BooleanField(null=True, blank=True)
    physical_disability = models.CharField(max_length=200, blank=True)
    have_history_of_chronic_illness = models.BooleanField(null=True, blank=True)
    history_of_chronic_illness = models.CharField(max_length=200, blank=True)
    have_been_arrested = models.BooleanField(null=True, blank=True)
    cause_of_arrest = models.CharField(max_length=200, blank=True)
    pending_high_school_balances = models.BooleanField(null=True, blank=True)
    school_balances = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)

    # ── Community / leadership ────────────────────────────────────────────
    held_leadership_position = models.BooleanField(null=True, blank=True)
    community_service_participation = models.BooleanField(null=True, blank=True)
    currently_volunteering = models.BooleanField(null=True, blank=True)
    member_of_group = models.BooleanField(null=True, blank=True)

    # ── Employment ────────────────────────────────────────────────────────
    ever_been_employed = models.BooleanField(null=True, blank=True)
    employer_support = models.BooleanField(null=True, blank=True)

    # ── Long-form narrative fields ────────────────────────────────────────
    challenge = models.TextField(blank=True, help_text="Greatest challenge overcome")
    experience = models.TextField(blank=True)
    most_significant_contribution = models.TextField(blank=True)
    most_significant_leadership_contribution = models.TextField(blank=True)

    # ── Home validation ───────────────────────────────────────────────────
    home_validation_status = models.CharField(
        max_length=16,
        choices=HomeValidationStatus.choices,
        default=HomeValidationStatus.NOT_REQUIRED,
        db_index=True,
    )
    home_validation_notes = models.TextField(blank=True)
    home_validated_at = models.DateTimeField(null=True, blank=True)

    # ── Compliance flags ──────────────────────────────────────────────────
    validated_academic_document = models.BooleanField(null=True, blank=True)
    validated_reference_letters = models.BooleanField(null=True, blank=True)

    # ── Source tracking ───────────────────────────────────────────────────
    scholarship_call_source = models.CharField(
        max_length=64, blank=True,
        help_text="How applicant heard about the call (e.g. social_media, university_notice)"
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = "Scholarship application profile"
        verbose_name_plural = "Scholarship application profiles"

    def __str__(self):
        return f"ScholarshipProfile for application {self.application_id}"


# ── Scholarship application child records ────────────────────────────────────
# Old-system parity: these repeating rows existed as separate legacy models
# keyed to Scholarshipapplication (Employmenthistory, Leadershipposition,
# Parent, Publication/ResearchAndPublication, Communityservice,
# Currentvolunteering, Groupassociationclub, Householdincomesource,
# Additionalfundingsource, Referenceletter, Transcriptfile,
# Mastercardeducation/Othereducation). ScholarshipApplicationProfile above
# only has flat/singular boolean flags for these categories (e.g.
# ``held_leadership_position``) — these child tables restore the repeating
# detail behind those flags. Near-duplicate old models are merged where the
# distinction wasn't meaningful (Employmenthistory+Workexperience,
# Publication+ResearchAndPublication, Mastercardeducation+Othereducation).

class ApplicantEmployment(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="employment_history")
    organisation = models.CharField(max_length=255)
    job_title = models.CharField(max_length=255, blank=True)
    organisation_address = models.TextField(blank=True)
    professional_responsibilities = models.TextField(blank=True)
    employed_from = models.DateField(null=True, blank=True)
    employed_to = models.DateField(null=True, blank=True)
    average_monthly_salary = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
    reason_for_leaving = models.CharField(max_length=255, blank=True)

    class Meta:
        ordering = ["-employed_from"]


class ApplicantLeadershipPosition(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="leadership_positions")
    position = models.CharField(max_length=150)
    group_name = models.CharField(max_length=150, verbose_name="Club / association")
    start_year = models.PositiveSmallIntegerField(null=True, blank=True)
    end_year = models.PositiveSmallIntegerField(null=True, blank=True)
    certificate = models.FileField(upload_to=upload_to_grants, blank=True, null=True)

    class Meta:
        ordering = ["-start_year"]


class ApplicantParent(models.Model):
    class Relationship(models.TextChoices):
        FATHER = "father", "Father"
        MOTHER = "mother", "Mother"
        GUARDIAN = "guardian", "Guardian"

    class Status(models.TextChoices):
        ALIVE = "alive", "Alive"
        DECEASED = "deceased", "Deceased"

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="parents")
    full_name = models.CharField(max_length=200)
    relationship = models.CharField(max_length=16, choices=Relationship.choices)
    status = models.CharField(max_length=16, choices=Status.choices, blank=True)
    date_of_death = models.DateField(null=True, blank=True)
    death_certificate = models.FileField(upload_to=upload_to_grants, blank=True, null=True)
    is_disabled = models.BooleanField(null=True, blank=True)
    disability_description = models.TextField(blank=True)
    occupation = models.CharField(max_length=150, blank=True)
    organisation = models.CharField(max_length=200, blank=True)
    gross_annual_income = models.CharField(max_length=64, blank=True, help_text="Income range, e.g. 'USD 0-500'")
    email = models.EmailField(blank=True)
    phone = models.CharField(max_length=32, blank=True)


class ApplicantPublication(models.Model):
    class Category(models.TextChoices):
        RESEARCH = "research", "Research project"
        PUBLICATION = "publication", "Journal / conference publication"
        POLICY_BRIEF = "policy_brief", "Policy brief"
        TECHNICAL_REPORT = "technical_report", "Technical report"

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="publications")
    category = models.CharField(max_length=24, choices=Category.choices, blank=True)
    title = models.CharField(max_length=500)
    description = models.TextField(blank=True)
    file = models.FileField(upload_to=upload_to_grants, blank=True, null=True)


class ApplicantCommunityService(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="community_service")
    activity = models.CharField(max_length=150)
    group_name = models.CharField(max_length=150, verbose_name="Association / club")
    start_year = models.PositiveSmallIntegerField(null=True, blank=True)
    end_year = models.PositiveSmallIntegerField(null=True, blank=True)

    class Meta:
        ordering = ["-start_year"]


class ApplicantVolunteering(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="current_volunteering")
    involvement = models.CharField(max_length=200)


class ApplicantGroupMembership(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="group_memberships")
    name = models.CharField(max_length=150)
    role = models.CharField(max_length=150, blank=True)


class ApplicantIncomeSource(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="household_income_sources")
    source = models.CharField(max_length=200)
    amount_range = models.CharField(max_length=64, blank=True, help_text="Annual amount range, e.g. 'USD 0-500'")


class ApplicantFundingSource(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="additional_funding_sources")
    already_funded = models.BooleanField(default=False)
    source_of_funding = models.CharField(max_length=255, blank=True)
    amount_already_provided = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
    funding_type = models.CharField(max_length=64, blank=True)


class ApplicantReference(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="reference_letters")
    referee_name = models.CharField(max_length=255)
    referee_address = models.TextField(blank=True)
    contact_details = models.TextField(blank=True)
    file = models.FileField(upload_to=upload_to_grants, blank=True, null=True)


class ApplicantTranscript(models.Model):
    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="transcripts")
    file = models.FileField(upload_to=upload_to_grants)


class ApplicantPriorEducation(models.Model):
    """Merges old ``Mastercardeducation`` (structured, per-school) and
    ``Othereducation`` (freeform qualification) into one repeating record."""

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="prior_education")
    qualification = models.CharField(max_length=150, blank=True, help_text="e.g. 'O-Level', 'A-Level', 'Diploma'")
    institution = models.CharField(max_length=255, blank=True)
    location = models.CharField(max_length=255, blank=True)
    country = models.CharField(max_length=3, blank=True, help_text="ISO 3166-1 alpha-3")
    school_ownership = models.CharField(max_length=64, blank=True, help_text="e.g. government, private, religious")
    major = models.CharField(max_length=150, blank=True)
    date_from = models.DateField(null=True, blank=True)
    date_to = models.DateField(null=True, blank=True)
    year_of_admission = models.PositiveSmallIntegerField(null=True, blank=True)
    year_of_completion = models.PositiveSmallIntegerField(null=True, blank=True)
    total_score = models.CharField(max_length=32, blank=True)
    average_cost_of_fees_per_year = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
    file = models.FileField(upload_to=upload_to_grants, blank=True, null=True)

    class Meta:
        ordering = ["-year_of_completion"]


class FellowshipApplicationProfile(models.Model):
    """Extended profile for fellowship-type applications (GTA, Staff Mobility, Post-doc).

    Attached 1:1 to an Application whose call.call_type == 'fellowship'.
    Stores placement-specific data: home institution, host institution,
    duration, teaching load, and placement confirmation status.
    """

    class PlacementStatus(models.TextChoices):
        PENDING = "pending", "Pending"
        HOST_CONFIRMED = "host_confirmed", "Host institution confirmed"
        TRAVEL_ARRANGED = "travel_arranged", "Travel arranged"
        PLACED = "placed", "Currently placed"
        COMPLETED = "completed", "Placement completed"
        CANCELLED = "cancelled", "Cancelled"

    class FormOfService(models.TextChoices):
        LECTURES = "lectures", "Lectures"
        RESEARCH_COLLABORATION = "research_collaboration", "Research collaboration"
        SUPERVISION = "supervision", "Supervision"
        OTHER = "other", "Other"

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="fellowship_profile",
    )

    # ── Home institution ──────────────────────────────────────────────────
    home_institute_name = models.CharField(max_length=255, blank=True)
    home_institute_department = models.CharField(max_length=200, blank=True)
    home_institute_faculty = models.CharField(max_length=200, blank=True)
    home_institute_place = models.CharField(max_length=255, blank=True)
    home_country = models.CharField(max_length=64, blank=True)

    # ── Host institution ──────────────────────────────────────────────────
    host_institute_name = models.CharField(max_length=255, blank=True)
    host_institute_department = models.CharField(max_length=200, blank=True)
    host_institute_faculty = models.CharField(max_length=200, blank=True)
    host_institute_place = models.CharField(max_length=255, blank=True)

    # ── Fellowship details ────────────────────────────────────────────────
    duration_weeks = models.PositiveSmallIntegerField(
        null=True, blank=True,
        help_text="Planned duration in weeks"
    )
    proposed_start = models.DateField(null=True, blank=True)
    proposed_end = models.DateField(null=True, blank=True)
    subject_area = models.CharField(max_length=500, blank=True)
    area_of_interest = models.CharField(max_length=255, blank=True)
    area_of_specialization = models.CharField(max_length=200, blank=True)
    form_of_service = models.CharField(
        max_length=32,
        choices=FormOfService.choices,
        blank=True,
    )
    job_title = models.CharField(max_length=100, blank=True)
    position = models.CharField(max_length=150, blank=True)
    telephone = models.CharField(max_length=64, blank=True)

    # ── Activities / outputs ──────────────────────────────────────────────
    proposed_activities = models.TextField(blank=True, help_text="What the fellow will deliver")
    other_activities = models.TextField(blank=True)
    motivation = models.TextField(blank=True)

    # ── Compliance validation ─────────────────────────────────────────────
    validated_cv = models.BooleanField(null=True, blank=True)
    validated_home_institute = models.BooleanField(null=True, blank=True)
    validated_host_institute = models.BooleanField(null=True, blank=True)
    validated_letter_of_release = models.BooleanField(null=True, blank=True)

    # ── Placement tracking ────────────────────────────────────────────────
    placement_status = models.CharField(
        max_length=24,
        choices=PlacementStatus.choices,
        default=PlacementStatus.PENDING,
        db_index=True,
    )
    placement_confirmed_at = models.DateTimeField(null=True, blank=True)
    placement_notes = models.TextField(blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = "Fellowship application profile"
        verbose_name_plural = "Fellowship application profiles"

    def __str__(self):
        return (
            f"FellowshipProfile for application {self.application_id} — "
            f"{self.home_institute_name} → {self.host_institute_name}"
        )


class PsychometricAssessment(models.Model):
    """Full applicant self-report psychometric questionnaire.

    Old-system parity: legacy ``scholarships.ApplicantHouseHoldSurvey``
    ("Psychometric Test Model") — ~90 items across achievement motivation,
    career values, parental influence, leadership behavior, agri-
    entrepreneurship attitudes, personality traits, and life satisfaction.
    Scholarship calls only. ``Application.psychometric_score`` remains the
    staff-confirmed final score once this questionnaire is filled in.
    """

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="psychometric_assessment",
    )

    # ── Basic info ────────────────────────────────────────────────────────
    surname = models.CharField(max_length=100, blank=True)
    other_names = models.CharField(max_length=100, blank=True)
    gender = models.CharField(max_length=16, blank=True)
    age = models.PositiveSmallIntegerField(null=True, blank=True)
    university = models.CharField(max_length=255, blank=True)
    course = models.CharField(max_length=255, blank=True)

    # ── Section B1 — Achievement motivation (LIKE_ME_CHOICES) ─────────────
    to_be_the_best = models.IntegerField(choices=LIKE_ME_CHOICES, null=True, blank=True)
    achieved_goal_that_took_years = models.IntegerField(choices=LIKE_ME_CHOICES, null=True, blank=True)
    being_thankful = models.IntegerField(choices=LIKE_ME_CHOICES, null=True, blank=True)
    finsh_whatever = models.IntegerField(choices=LIKE_ME_CHOICES, null=True, blank=True)
    lasting_importance_achievement = models.IntegerField(choices=LIKE_ME_CHOICES, null=True, blank=True)

    # ── Section B2 — Career values (IMPORTANT_CHOICES) ────────────────────
    earn_good_money_job = models.IntegerField(choices=IMPORTANT_CHOICES, null=True, blank=True)
    creativity_job = models.IntegerField(choices=IMPORTANT_CHOICES, null=True, blank=True)
    positive_contribution = models.IntegerField(choices=IMPORTANT_CHOICES, null=True, blank=True)
    visible_payoff = models.IntegerField(choices=IMPORTANT_CHOICES, null=True, blank=True)
    help_others_job = models.IntegerField(choices=IMPORTANT_CHOICES, null=True, blank=True)

    # ── Section B3 — Parental influence (TRUE_CHOICES) ────────────────────
    parent_academic_expections = models.IntegerField(choices=TRUE_CHOICES, null=True, blank=True)
    no_parent_pressure = models.IntegerField(choices=TRUE_CHOICES, null=True, blank=True)
    parent_career_expections = models.IntegerField(choices=TRUE_CHOICES, null=True, blank=True)
    similar_career_as_parent = models.IntegerField(choices=TRUE_CHOICES, null=True, blank=True)
    parent_different_expections = models.IntegerField(choices=TRUE_CHOICES, null=True, blank=True)

    # ── Section C — Career aspirations (free text) ────────────────────────
    exact_job_title = models.CharField(max_length=100, blank=True)
    reason_for_job_title = models.TextField(max_length=500, blank=True)

    # ── Section D — Leadership behavior (OFTEN_CHOICES) ───────────────────
    seek_challenging_opportunities = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    talk_about_future_trends = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    develop_cooperative_relationships = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    lead_by_example = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    commend_good_work = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    encourage_innovation = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    envision_inspiring_future = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    embrace_perspectives = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    uphold_standards = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    express_confidence = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    seek_innovation = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    inspire_vision = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    show_respect = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    honor_commitments = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    reward_contributions = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    ask_solutions = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    promote_collaboration = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    support_autonomy = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    clarify_leadership = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    celebrate_dedication = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    embrace_risk = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    stay_optimistic = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    encourage_autonomy = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    set_clear_goals = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    celebrate_success = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    take_initiative = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    speak_with_purpose = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    foster_growth = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    progress_steadily = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)
    value_contributions = models.IntegerField(choices=OFTEN_CHOICES, null=True, blank=True)

    # ── Section E — Agri-entrepreneurship attitudes (AGREE_CHOICES) ───────
    entrepreneurship_drives_profit = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    entrepreneurs_capitalize_on_opportunities = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    entrepreneurs_fear_uncertainty_and_failure = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    entrepreneurs_need_support_to_succeed = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    entrepreneurs_value_client_feedback = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_is_business = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agribusiness_creates_jobs = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_offers_youth_best_opportunity = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agricultural_entrepreneurship_is_profitable = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_suits_those_from_poor_backgrounds = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agricultural_is_noble = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_drives_community_development = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    farming_yields_lower_profits = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_provides_sustainable_income = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agribusiness_opportunity = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    parental_support_ag_science = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    can_learn_something_else = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    first_choice_program_agriculture = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_science_future_benefit = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    interested_in_profitable_agriculture = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    agriculture_offers_career_opportunities = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    not_interested_in_agriculture_career = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    see_self_in_agriculture_career = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    parents_disapprove_agriculture_career = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)
    community_impact = models.IntegerField(choices=AGREE_CHOICES, null=True, blank=True)

    # ── Section F — Personality / life satisfaction (SCALE_ONE_TO_TEN) ────
    open_expression = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    speaks_up = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    takes_charge = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    listens_to_others = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    lets_others_lead = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    is_assertive = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    is_competitive = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    persuasive = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    blends_in = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_family = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_housing = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_family_finances = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_community_involvement = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_future_prospects = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    satisfied_with_current_life = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)

    # ── Section G — Personality traits (SCALE_ONE_TO_TEN) ─────────────────
    action_oriented = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    adaptable = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    ambitious = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    approachable = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    creative = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    competitive = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    confident = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    empathetic = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    flexible = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    focused = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    forward_thinking = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    honest = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    materialistic = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    open_minded = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    optimistic = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    pragmatic = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    patient = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    easily_gives_up = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    trusting = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)
    responsible = models.IntegerField(choices=SCALE_ONE_TO_TEN_CHOICES, null=True, blank=True)

    # ── Self-reflection (free text) ────────────────────────────────────────
    top_three_leader_traits = models.TextField(max_length=200, blank=True)
    top_three_entrepreneur_traits = models.TextField(max_length=100, blank=True)
    top_three_personality_traits = models.TextField(max_length=100, blank=True)

    submitted_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = "Psychometric assessment"
        verbose_name_plural = "Psychometric assessments"

    def __str__(self):
        return f"Psychometric assessment for application {self.application_id}"

    def clean(self):
        super().clean()
        if self.application_id and self.application.call.call_type != GrantCall.CallType.SCHOLARSHIP:
            raise ValidationError("Psychometric assessments only apply to scholarship applications.")


class PsychometricAssessmentAttempt(models.Model):
    """Tracks attempt usage against the SRS's max-3-attempts rule.

    The old system had no attempt cap (a single self-report row per
    application); this is new, enforcing the SRS §4.1.4 "maximum of three
    psychometric test attempts" business rule.
    """

    assessment = models.ForeignKey(
        PsychometricAssessment, on_delete=models.CASCADE, related_name="attempts"
    )
    attempt_number = models.PositiveSmallIntegerField()
    started_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["attempt_number"]
        constraints = [
            models.UniqueConstraint(
                fields=["assessment", "attempt_number"],
                name="uniq_psychometric_attempt_number",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"Attempt {self.attempt_number} for assessment {self.assessment_id}"


MAX_PSYCHOMETRIC_ATTEMPTS = 3


class ChallengeApplicationProfile(models.Model):
    """Extended profile for challenge-type applications (innovation bids).

    Attached 1:1 to an Application whose call.call_type == 'challenge'.
    Old-system parity: legacy ``challengehub`` app had call/award-level
    challenge fields but no applicant-side profile — this closes that gap
    alongside ``ApplicationPartner`` below.
    """

    # PrototypeStatus is defined at module level (shared with ChallengeMilestoneReport).
    PrototypeStatus = PrototypeStatus

    application = models.OneToOneField(
        Application,
        on_delete=models.CASCADE,
        related_name="challenge_profile",
    )
    innovation_stage = models.CharField(
        max_length=24,
        choices=GrantCall.InnovationStage.choices,
        blank=True,
        help_text="Applicant's self-assessed stage — compare against the call's targeted stage.",
    )
    problem_statement = models.TextField(blank=True)
    proposed_solution = models.TextField(blank=True)
    target_beneficiaries = models.TextField(blank=True)
    team_size = models.PositiveSmallIntegerField(null=True, blank=True)
    prototype_status = models.CharField(max_length=24, choices=PrototypeStatus.choices, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = "Challenge application profile"
        verbose_name_plural = "Challenge application profiles"

    def __str__(self):
        return f"ChallengeProfile for application {self.application_id}"


class ApplicationPartner(models.Model):
    """Consortium / bidding partner organisation on an application.

    Old-system parity: legacy ``challengehub.ApplicationPartners``. Scoped
    generically to ``Application`` (not challenge-only) so grant consortium
    applications can reuse it later without a schema change.
    """

    application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="partners")
    organization_name = models.CharField(max_length=200)
    website = models.URLField(blank=True)
    contact_person = models.CharField(max_length=200)
    email = models.EmailField()
    phone = models.CharField(max_length=20)
    address = models.TextField()
    area_of_expertise = models.CharField(max_length=200)

    def __str__(self):  # pragma: no cover
        return self.organization_name


class RimsCapability(models.Model):
    """Sentinel model — holds Django permissions for RIMS RBAC (no business rows required)."""

    class Meta:
        verbose_name = "RIMS capability"
        verbose_name_plural = "RIMS capabilities"
        default_permissions = ()
        permissions = [
            ("view_dashboard", "Can view the RIMS consolidated dashboard"),
            ("manage_calls", "Can manage grant calls, applications, and awards"),
            ("review_applications", "Can review assigned grant applications"),
            ("manage_operations", "Can manage RIMS partners, institutions, and MOUs"),
            ("manage_finance", "Can manage RIMS budgets and disbursements"),
            ("manage_projects", "Can manage RIMS projects and milestones"),
            ("manage_scholarships", "Can manage scholarships and scholars"),
            (
                "approve_grants",
                "Can approve or reject funding records in grant planning workflows",
            ),
            (
                "manage_screening",
                "Can perform Program Officer document-completeness screening on submitted applications",
            ),
        ]
