from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models

from apps.core.money import DEFAULT_CURRENCY, ISO4217_CHOICES
from apps.core.storage import FileValidator
from apps.core.storage.paths import upload_to_finance
from apps.rims.grants.models import Award

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


class FundingSource(models.Model):
    name = models.CharField(max_length=255, unique=True)
    code = models.SlugField(max_length=64, unique=True)
    description = models.TextField(blank=True)
    partner = models.ForeignKey(
        "rims_operations.Partner",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="funding_sources",
        help_text="Donor/partner this funding stream represents.",
    )

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Budget(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        APPROVED = "approved", "Approved"
        FINALIZED = "finalized", "Finalized"

    award = models.ForeignKey(Award, on_delete=models.CASCADE, related_name="budgets")
    name = models.CharField(max_length=255)
    fiscal_year = models.PositiveSmallIntegerField(null=True, blank=True)
    # PRD §5.4 FRFM016, NFRFM002 — base currency for this budget. Defaults to
    # the parent award's currency. The clean() check enforces that the two
    # match; conversion happens at report time, not at storage time.
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    funding_source = models.ForeignKey(
        FundingSource,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="budgets",
    )
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
    account_reference = models.CharField(max_length=96, blank=True)
    approved_at = models.DateTimeField(null=True, blank=True)
    finalized_at = models.DateTimeField(null=True, blank=True)
    # PRD §5.4 FRFM001/004 — multi-year budget planning. ``is_multi_year`` flips
    # the form variant; period_start/period_end describe the funded window when
    # set so callers can derive per-year allocations via BudgetYearAllocation.
    is_multi_year = models.BooleanField(default=False)
    period_start = models.DateField(null=True, blank=True)
    period_end = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def clean(self):
        from django.core.exceptions import ValidationError as _VE

        super().clean()
        if self.award_id and self.currency and self.award.currency:
            if self.currency != self.award.currency:
                raise _VE(
                    {
                        "currency": (
                            f"Budget currency {self.currency} does not match "
                            f"award currency {self.award.currency}."
                        )
                    }
                )
        if self.is_multi_year and self.period_start and self.period_end:
            if self.period_end < self.period_start:
                raise _VE({"period_end": "Period end must be on or after period start."})

    class Meta:
        ordering = ["-created_at"]
        verbose_name_plural = "Budgets"

    def __str__(self):
        return f"{self.name} ({self.award_id})"


class BudgetYearAllocation(models.Model):
    """PRD §5.4 FRFM001 — per-fiscal-year slot on a multi-year budget.

    Splits the parent budget's total into fiscal-year buckets so program teams
    can plan multi-year grants without losing the headline total. The sum of
    allocations is validated against ``Budget.total_amount`` by the service.
    """

    budget = models.ForeignKey(
        Budget,
        on_delete=models.CASCADE,
        related_name="year_allocations",
    )
    fiscal_year = models.PositiveSmallIntegerField()
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    notes = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["fiscal_year"]
        constraints = [
            models.UniqueConstraint(
                fields=["budget", "fiscal_year"],
                name="uniq_budget_year_allocation",
            ),
        ]

    def __str__(self):
        return f"{self.budget.name} — FY{self.fiscal_year}: {self.amount} {self.currency}"


class BudgetLine(models.Model):
    class Category(models.TextChoices):
        PERSONNEL = "personnel", "Personnel"
        TRAVEL = "travel", "Travel"
        EQUIPMENT = "equipment", "Equipment"
        TRAINING = "training", "Training"
        OPERATIONS = "operations", "Operations"
        OTHER = "other", "Other"

    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name="lines")
    label = models.CharField(max_length=255)
    category = models.CharField(max_length=24, choices=Category.choices, default=Category.OTHER)
    quantity = models.DecimalField(max_digits=10, decimal_places=2, default=1)
    unit = models.CharField(max_length=64, blank=True)
    unit_cost = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    account_code = models.CharField(max_length=64, blank=True)
    allocation_year = models.PositiveSmallIntegerField(null=True, blank=True)
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    # PRD §5.4 FRFM005 — line-level funding source assignment. Optional; when
    # blank, the line inherits Budget.funding_source for reporting purposes.
    # Different lines on the same budget can sit against different donors.
    funding_source = models.ForeignKey(
        FundingSource,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="budget_lines",
        help_text=(
            "FRFM005 — donor / funding stream this line draws from. Leave blank "
            "to inherit the budget-level funding source."
        ),
    )

    class Meta:
        ordering = ["pk"]

    def __str__(self):
        return self.label or f"Budget line #{self.pk}"

    @property
    def effective_funding_source(self) -> "FundingSource | None":
        """Resolve the line-level source, falling back to the budget header."""
        return self.funding_source or self.budget.funding_source


class Expenditure(models.Model):
    budget_line = models.ForeignKey(BudgetLine, on_delete=models.CASCADE, related_name="expenditures")
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    description = models.CharField(max_length=255, blank=True)
    incurred_on = models.DateField()
    receipt = models.FileField(upload_to=upload_to_finance, blank=True, null=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    created_at = models.DateTimeField(auto_now_add=True)


class DisbursementRequest(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"
        PAID = "paid", "Paid"

    class PaymentMethod(models.TextChoices):
        BANK_TRANSFER = "bank_transfer", "Bank transfer"
        MOBILE_MONEY = "mobile_money", "Mobile money"
        CHEQUE = "cheque", "Cheque"
        CASH = "cash", "Cash"
        OTHER = "other", "Other"

    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name="disbursement_requests")
    milestone = models.ForeignKey(
        "rims_projects.Milestone",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="disbursement_requests",
        help_text="Optional: disbursement milestone gate. Must be accepted before approval.",
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    justification = models.TextField(blank=True)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
    payment_method = models.CharField(
        max_length=24,
        choices=PaymentMethod.choices,
        default=PaymentMethod.BANK_TRANSFER,
    )
    scheduled_for = models.DateField(null=True, blank=True)
    account_reference = models.CharField(max_length=96, blank=True)
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="disbursement_requests_approved",
    )
    approved_at = models.DateTimeField(null=True, blank=True)
    rejection_reason = models.TextField(blank=True)
    supporting_document = models.FileField(
        upload_to=upload_to_finance,
        blank=True,
        null=True,
        validators=[_finance_doc_validator],
    )
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="disbursement_requests",
    )
    # PRD §5.4 FRFM046 — payee notification on payment execution. When set,
    # the EXECUTED transition fires an email to this address. When blank, the
    # requester is notified instead.
    payee_email = models.EmailField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class DisbursementExecution(models.Model):
    class Status(models.TextChoices):
        SCHEDULED = "scheduled", "Scheduled"
        EXECUTED = "executed", "Executed"
        RESCHEDULED = "rescheduled", "Rescheduled"
        FAILED = "failed", "Failed"
        # P0 (assessment §3 #8) — payment reversed post-execution (bank chargeback).
        REVERSED = "reversed", "Reversed"

    # PRD §5.4 FRFM042/043 — failure-mode telemetry (set when status FAILED).
    class FailureCategory(models.TextChoices):
        NSF = "nsf", "Insufficient funds"
        WRONG_ACCOUNT = "wrong_account", "Wrong account"
        BANK_REJECTION = "bank_rejection", "Bank rejection"
        OTHER = "other", "Other"

    class ProofChannel(models.TextChoices):
        BANK = "bank", "Bank transfer receipt"
        MOBILE_MONEY = "mobile_money", "Mobile money confirmation"
        CHEQUE_IMAGE = "cheque", "Cheque scan"
        CASH_VOUCHER = "cash_voucher", "Cash voucher"
        OTHER = "other", "Other"

    request = models.OneToOneField(
        DisbursementRequest,
        on_delete=models.CASCADE,
        related_name="execution",
    )
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.SCHEDULED)
    payment_method = models.CharField(
        max_length=24,
        choices=DisbursementRequest.PaymentMethod.choices,
        default=DisbursementRequest.PaymentMethod.BANK_TRANSFER,
    )
    scheduled_for = models.DateField(null=True, blank=True)
    executed_at = models.DateTimeField(null=True, blank=True)
    execution_reference = models.CharField(
        max_length=96,
        blank=True,
        help_text=(
            "PRD §5.4 / SRS use-case step 4 — transaction reference returned by "
            "the payment platform (bank reference, mobile-money transaction ID)."
        ),
    )
    destination_account = models.CharField(max_length=96, blank=True)
    notes = models.TextField(blank=True)
    reschedule_history = models.JSONField(default=list, blank=True)
    # PRD §5.4 FRFM045 — payment documentation. The executor uploads the
    # platform-issued receipt (bank slip PDF, mobile-money confirmation
    # screenshot, cheque scan) as evidence of payment for audit/donor review.
    proof_of_payment = models.FileField(
        upload_to=upload_to_finance,
        blank=True,
        null=True,
        help_text="Receipt or confirmation file from the bank or mobile money platform.",
    )
    proof_channel = models.CharField(
        max_length=20,
        choices=ProofChannel.choices,
        blank=True,
        help_text="Origin of the proof-of-payment file (bank, mobile money, cheque, cash voucher).",
    )
    proof_received_at = models.DateTimeField(null=True, blank=True)
    proof_uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="disbursement_proofs_uploaded",
    )
    # P0 (assessment §3 #8) — payment reversal metadata.
    reversed_at = models.DateTimeField(null=True, blank=True)
    reversal_reference = models.CharField(max_length=96, blank=True)
    reversal_reason = models.TextField(blank=True)
    # PRD §5.4 FRFM042/043 — failure-mode metadata.
    failure_reason = models.CharField(max_length=255, blank=True)
    failure_category = models.CharField(
        max_length=24,
        blank=True,
    )
    # PRD §5.4 FRFM046 — synchronous GL posting status. Defaults to "pending"
    # so a signal-write failure does not propagate into the request cycle;
    # the daily reconcile beat re-posts pending rows.
    gl_posting_status = models.CharField(
        max_length=16,
        default="pending",
        help_text='"pending", "posted", or "flagged".',
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-updated_at"]

    @property
    def has_proof(self) -> bool:
        return bool(self.proof_of_payment)


class DisbursementReconciliation(models.Model):
    """PRD §5.4 FRFM047/048 — bank/mobile-money reconciliation evidence.

    Finance officers attach the bank statement excerpt or mobile money
    statement that confirms the payment landed (and matches the captured
    transaction reference).
    """

    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        RECONCILED = "reconciled", "Reconciled"
        DISCREPANCY = "discrepancy", "Discrepancy"

    class StatementSource(models.TextChoices):
        BANK = "bank", "Bank statement"
        MOBILE_MONEY = "mobile_money", "Mobile money statement"
        CASH_LEDGER = "cash_ledger", "Cash ledger"
        OTHER = "other", "Other"

    request = models.OneToOneField(
        DisbursementRequest,
        on_delete=models.CASCADE,
        related_name="reconciliation",
    )
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
    notes = models.TextField(blank=True)
    # PRD §5.4 FRFM047 — attach the bank or mobile-money statement excerpt
    # that proves the payment. This is the audit-trail evidence the donor
    # reporting layer (FRFM050) cites for "matched against bank statement".
    statement_excerpt = models.FileField(
        upload_to=upload_to_finance,
        blank=True,
        null=True,
        help_text="Bank statement page or mobile-money statement screenshot evidencing the payment.",
    )
    statement_source = models.CharField(
        max_length=20,
        choices=StatementSource.choices,
        blank=True,
        help_text="Where the statement excerpt came from.",
    )
    statement_period_start = models.DateField(
        null=True,
        blank=True,
        help_text="Inclusive start of the period covered by the statement excerpt.",
    )
    statement_period_end = models.DateField(
        null=True,
        blank=True,
        help_text="Inclusive end of the period covered by the statement excerpt.",
    )
    discrepancy_amount = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        null=True,
        blank=True,
        help_text="Signed amount difference when status=DISCREPANCY (positive = overpaid).",
    )
    reconciled_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="disbursement_reconciliations",
    )
    reconciled_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-updated_at"]


class PaymentSchedule(models.Model):
    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name="payment_schedules")
    due_on = models.DateField()
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    label = models.CharField(max_length=128, blank=True)
    paid = models.BooleanField(default=False)
    # PRD §5.4 FRFM026 — track when the due-date reminder was last sent so the
    # daily task can debounce.
    last_reminded_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["due_on"]


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM016 / NFRFM002 — multi-currency support.
# ExchangeRate stores manually-entered rates with effective dates. The
# convert() service in finance/services.py picks the most recent rate at-or-
# before the requested as_of date.
# ---------------------------------------------------------------------------
class ExchangeRate(models.Model):
    """Manually-curated exchange rate row (PRD §5.4 FRFM016)."""

    from_currency = models.CharField(max_length=8, choices=ISO4217_CHOICES)
    to_currency = models.CharField(max_length=8, choices=ISO4217_CHOICES)
    rate = models.DecimalField(
        max_digits=18,
        decimal_places=8,
        help_text="Multiply a from_currency amount by this rate to get a to_currency amount.",
    )
    effective_date = models.DateField(
        help_text="Date this rate applies from. The most recent row at-or-before the report date wins.",
    )
    source = models.CharField(
        max_length=120,
        blank=True,
        help_text="Optional source / reference (e.g. 'BoU 2026-04-01').",
    )
    recorded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="exchange_rates_recorded",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-effective_date", "from_currency", "to_currency"]
        constraints = [
            models.UniqueConstraint(
                fields=["from_currency", "to_currency", "effective_date"],
                name="uniq_exchange_rate_per_pair_per_date",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"1 {self.from_currency} = {self.rate} {self.to_currency} ({self.effective_date})"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM010 / FRFM011 / NFRFM003 — formal budget revisions with version
# control + approval workflow.
# ---------------------------------------------------------------------------
class BudgetRevision(models.Model):
    class Status(models.TextChoices):
        REQUESTED = "requested", "Requested"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"

    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name="revisions")
    version = models.PositiveIntegerField(
        help_text="Sequential version number; first revision = 1, second = 2, ...",
    )
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.REQUESTED,
        db_index=True,
    )
    change_summary = models.TextField(
        help_text="What is changing and why. Required for the audit trail.",
    )
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="budget_revisions_requested",
    )
    requested_at = models.DateTimeField(auto_now_add=True)
    decided_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="budget_revisions_decided",
    )
    decided_at = models.DateTimeField(null=True, blank=True)
    decided_comment = models.TextField(blank=True)

    class Meta:
        ordering = ["-version", "-pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["budget", "version"],
                name="uniq_budget_revision_version",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"Revision v{self.version} of budget {self.budget_id}"


class BudgetRevisionLine(models.Model):
    """Per-budget-line delta belonging to a BudgetRevision.

    Captures the *requested* before/after amounts so the approver can audit
    the change. When the revision is approved, ``apply_to_budget()`` writes
    new_amount onto the underlying BudgetLine."""

    revision = models.ForeignKey(BudgetRevision, on_delete=models.CASCADE, related_name="lines")
    line = models.ForeignKey(BudgetLine, on_delete=models.CASCADE, related_name="revision_changes")
    old_amount = models.DecimalField(max_digits=14, decimal_places=2)
    new_amount = models.DecimalField(max_digits=14, decimal_places=2)
    justification = models.TextField(blank=True)

    class Meta:
        ordering = ["pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["revision", "line"],
                name="uniq_budget_revision_line",
            ),
        ]


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM040 — payment batching. Finance officers assemble approved
# DisbursementRequests into a batch, lock it (so no edits sneak in), then
# execute every item in one operator action. Each item carries its own
# DisbursementExecution row underneath, so reporting and reconciliation stay
# per-payment.
# ---------------------------------------------------------------------------
class PaymentBatch(models.Model):
    """A grouping of approved disbursement requests scheduled for joint execution."""

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        LOCKED = "locked", "Locked"
        EXECUTED = "executed", "Executed"
        CANCELLED = "cancelled", "Cancelled"
        # P0 (assessment §3 #8) — chargeback / bank-reversal path for batches
        # that were executed but failed downstream (NSF, account closed,
        # operator mistake). Distinct from CANCELLED which is pre-execution.
        REVERSED = "reversed", "Reversed"

    batch_reference = models.CharField(
        max_length=64,
        unique=True,
        help_text="Human-readable batch reference (e.g. BATCH-2026-04-02-A).",
    )
    notes = models.TextField(blank=True)
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.DRAFT,
        db_index=True,
    )
    scheduled_for = models.DateField(
        null=True,
        blank=True,
        help_text="Target value-date for every payment in this batch.",
    )
    payment_method = models.CharField(
        max_length=24,
        choices=DisbursementRequest.PaymentMethod.choices,
        default=DisbursementRequest.PaymentMethod.BANK_TRANSFER,
    )
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
        help_text="All items in a batch must share this currency.",
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="payment_batches_created",
    )
    locked_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="payment_batches_locked",
    )
    locked_at = models.DateTimeField(null=True, blank=True)
    executed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="payment_batches_executed",
    )
    executed_at = models.DateTimeField(null=True, blank=True)
    cancelled_at = models.DateTimeField(null=True, blank=True)
    # P0 (assessment §3 #8) — reversal of an executed batch.
    reversed_at = models.DateTimeField(null=True, blank=True)
    reversed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="payment_batches_reversed",
    )
    reversal_reason = models.TextField(blank=True)
    reversal_reference = models.CharField(
        max_length=96,
        blank=True,
        help_text="Bank chargeback / reversal reference returned by the platform.",
    )
    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 self.batch_reference

    @property
    def total_amount(self):
        from decimal import Decimal as _Dec

        total = _Dec("0")
        for item in self.items.all():
            total += item.amount
        return total

    @property
    def is_editable(self) -> bool:
        return self.status == self.Status.DRAFT


class PaymentBatchItem(models.Model):
    """A single approved disbursement request enrolled into a batch.

    The 1:1 relation to ``DisbursementRequest`` prevents a request from being
    enrolled in more than one batch — a clear IntegrityError surface beats a
    silent double-payment.
    """

    batch = models.ForeignKey(PaymentBatch, on_delete=models.CASCADE, related_name="items")
    request = models.OneToOneField(
        DisbursementRequest,
        on_delete=models.PROTECT,
        related_name="batch_item",
        help_text="The approved DisbursementRequest this item executes.",
    )
    amount = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        help_text=(
            "Snapshot of the request amount at batch-creation time. The batch "
            "executor uses this rather than the live request amount so editing "
            "a request after batching is impossible without un-batching."
        ),
    )
    sequence = models.PositiveIntegerField(default=0, db_index=True)

    class Meta:
        ordering = ["sequence", "pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["batch", "request"],
                name="uniq_batch_item_per_request",
            ),
        ]

    def __str__(self):  # pragma: no cover
        return f"Item {self.request_id} in batch {self.batch_id}"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM012 / FRFM013 / FRFM015 — approval workflow configuration.
# PRD §5.4 FRFM036 / FRFM037 / FRFM038 — segregation-of-duties telemetry.
# ---------------------------------------------------------------------------
class BudgetApprovalWorkflow(models.Model):
    """PRD §5.4 FRFM012/013/015 — configuration record for budget approvals.

    Either ``award`` or ``funding_source`` may be set (one applies per record).
    ``approval_mode`` toggles line-level vs total-only approval; ``threshold_amount``
    optionally escalates to a stricter mode once a budget exceeds the cap;
    ``required_tiers`` lists the role strings that must approve before a budget
    is considered fully approved.
    """

    class Mode(models.TextChoices):
        LINE = "line", "Line-level"
        TOTAL = "total", "Total-only"

    award = models.ForeignKey(
        Award,
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        related_name="approval_workflows",
    )
    funding_source = models.ForeignKey(
        FundingSource,
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        related_name="approval_workflows",
    )
    approval_mode = models.CharField(
        max_length=8,
        choices=Mode.choices,
        default=Mode.TOTAL,
    )
    threshold_amount = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        null=True,
        blank=True,
        help_text="Above this amount, approval escalates (line-level if total mode, else stays line-level).",
    )
    required_tiers = models.JSONField(
        default=list,
        blank=True,
        help_text='List of role strings (e.g. ["finance_manager", "program_coordinator"]).',
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="budget_workflows_created",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        scope = self.award or self.funding_source or "Default"
        return f"{scope} — {self.get_approval_mode_display()}"


class FinanceApproval(models.Model):
    """PRD §5.4 FRFM036/037/038 — segregation-of-duties telemetry.

    Each approval (or rejection) on a Budget, BudgetLine, or DisbursementRequest
    emits a row so reviewers and auditors can answer "who approved which row at
    which tier?" without scanning the audit log. The two-eyes guard
    (services.require_two_eyes) consults this table on every approval action.
    """

    class Decision(models.TextChoices):
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"

    target_content_type = models.ForeignKey(
        ContentType,
        on_delete=models.CASCADE,
        related_name="+",
    )
    target_object_id = models.PositiveBigIntegerField()
    target = GenericForeignKey("target_content_type", "target_object_id")
    actor = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="finance_approvals",
    )
    tier = models.CharField(
        max_length=64,
        help_text='Role/tier as configured by BudgetApprovalWorkflow (e.g. "finance_manager").',
    )
    decision = models.CharField(
        max_length=16,
        choices=Decision.choices,
        default=Decision.APPROVED,
    )
    comment = models.TextField(blank=True)
    decided_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-decided_at"]
        indexes = [
            models.Index(fields=["target_content_type", "target_object_id"]),
            models.Index(fields=["actor", "decided_at"]),
        ]

    def __str__(self):
        return f"{self.actor} {self.decision} on {self.target_content_type} #{self.target_object_id}"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM027 — rolling-budget Reforecast (full implementation per user
# lock 2). Reforecast rows are immutable snapshots; the monthly Celery beat
# appends new rows instead of overwriting, so finance staff can see drift over
# time. Each Reforecast carries one ReforecastLine per (budget_line, bucket).
# ---------------------------------------------------------------------------
class Reforecast(models.Model):
    class Methodology(models.TextChoices):
        BURN_RATE = "burn_rate", "Historical burn rate"
        TRANCHE_SCHEDULE = "tranche_schedule", "Tranche schedule"
        BLENDED = "blended", "Blended"

    budget = models.ForeignKey(
        Budget,
        on_delete=models.CASCADE,
        related_name="reforecasts",
    )
    generated_at = models.DateTimeField(auto_now_add=True)
    generated_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="reforecasts_generated",
        help_text="NULL when generated by the monthly Celery beat.",
    )
    as_of_date = models.DateField()
    forecast_horizon_months = models.PositiveSmallIntegerField(default=12)
    methodology = models.CharField(
        max_length=24,
        choices=Methodology.choices,
        default=Methodology.BLENDED,
    )
    notes = models.TextField(blank=True)

    class Meta:
        ordering = ["-generated_at"]

    def __str__(self):
        return f"Reforecast({self.budget.name}, {self.as_of_date}, {self.methodology})"


class ReforecastLine(models.Model):
    reforecast = models.ForeignKey(
        Reforecast,
        on_delete=models.CASCADE,
        related_name="lines",
    )
    budget_line = models.ForeignKey(
        BudgetLine,
        on_delete=models.PROTECT,
        related_name="reforecast_lines",
    )
    bucket = models.CharField(
        max_length=7,
        help_text="YYYY-MM bucket label.",
    )
    forecasted_amount = models.DecimalField(max_digits=14, decimal_places=2)
    historical_burn = models.DecimalField(max_digits=14, decimal_places=2, default=0)
    tranche_inflow = models.DecimalField(max_digits=14, decimal_places=2, default=0)

    class Meta:
        ordering = ["bucket"]
        constraints = [
            models.UniqueConstraint(
                fields=["reforecast", "budget_line", "bucket"],
                name="uniq_reforecast_line_bucket",
            ),
        ]

    def __str__(self):
        return f"{self.budget_line.label} @ {self.bucket}: {self.forecasted_amount}"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM042 / FRFM043 — payment-failure retry queue.
# Created whenever a DisbursementExecution flips to FAILED. The Celery beat
# (`rims-finance-payment-retry-sweep`) advances QUEUED → RETRYING and lets
# finance officers escalate to RETRIED → PAID or ABANDONED via the UI.
# ---------------------------------------------------------------------------
class PaymentRetry(models.Model):
    class Status(models.TextChoices):
        QUEUED = "queued", "Queued"
        RETRYING = "retrying", "Retrying"
        RETRIED = "retried", "Retried"
        PAID = "paid", "Paid"
        ABANDONED = "abandoned", "Abandoned"

    execution = models.ForeignKey(
        DisbursementExecution,
        on_delete=models.CASCADE,
        related_name="retries",
    )
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.QUEUED,
    )
    attempts = models.PositiveSmallIntegerField(default=0)
    last_attempt_at = models.DateTimeField(null=True, blank=True)
    last_error = models.TextField(blank=True)
    next_retry_at = models.DateTimeField(null=True, blank=True)
    resolved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="payment_retries_resolved",
    )
    resolved_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["status", "next_retry_at"]),
        ]

    def __str__(self):
        return f"PaymentRetry({self.execution_id}, {self.status})"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM046 — General-ledger double-entry posting primitives.
# Each DisbursementExecution flips to EXECUTED, the post_save signal writes
# a debit + credit pair via services_gl.post_gl_entries. The daily Celery
# beat re-validates every recent posted row and flips broken pairs to
# "flagged", queuing a PaymentRetry for the parent execution.
# ---------------------------------------------------------------------------
class GLEntry(models.Model):
    class ValidationStatus(models.TextChoices):
        POSTED = "posted", "Posted"
        FLAGGED = "flagged", "Flagged"
        VOIDED = "voided", "Voided"

    execution = models.ForeignKey(
        DisbursementExecution,
        on_delete=models.PROTECT,
        related_name="gl_entries",
        help_text="PROTECT preserves the audit chain; void via void_gl_entry before delete.",
    )
    account_code = models.CharField(max_length=32)
    debit = models.DecimalField(max_digits=14, decimal_places=2, default=0)
    credit = models.DecimalField(max_digits=14, decimal_places=2, default=0)
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    narrative = models.CharField(max_length=255, blank=True)
    posted_at = models.DateTimeField(auto_now_add=True)
    posted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="gl_entries_posted",
        help_text="NULL when posted by the signal (executor of the save) — see services_gl.",
    )
    validation_status = models.CharField(
        max_length=12,
        choices=ValidationStatus.choices,
        default=ValidationStatus.POSTED,
    )
    validation_notes = models.TextField(blank=True)

    class Meta:
        ordering = ["-posted_at"]
        constraints = [
            models.CheckConstraint(
                check=models.Q(debit__gt=0) | models.Q(credit__gt=0),
                name="gl_entry_debit_or_credit_positive",
            ),
        ]
        indexes = [
            models.Index(fields=["execution"]),
            models.Index(fields=["validation_status", "posted_at"]),
        ]

    def __str__(self):
        side = f"Dr {self.debit}" if self.debit else f"Cr {self.credit}"
        return f"GL #{self.pk}: {self.account_code} {side} {self.currency}"


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM048 / FRFM049 — bank-statement uploads + auto-matching.
# Bank statements are uploaded by finance staff, parsed line-by-line, then
# matched against DisbursementExecution rows by amount + value-date proximity.
# Matched lines link to both the execution and the corresponding GLEntry so
# reconciliation reports trace from bank → ledger row.
# ---------------------------------------------------------------------------
class BankStatement(models.Model):
    name = models.CharField(max_length=255)
    period_start = models.DateField()
    period_end = models.DateField()
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="bank_statements_uploaded",
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)
    file = models.FileField(
        upload_to=upload_to_finance,
        validators=[_finance_doc_validator],
    )
    account_reference = models.CharField(max_length=96)

    class Meta:
        ordering = ["-uploaded_at"]

    def __str__(self):
        return f"{self.name} ({self.period_start} → {self.period_end})"


class BankStatementLine(models.Model):
    class MatchStatus(models.TextChoices):
        UNMATCHED = "unmatched", "Unmatched"
        MATCHED = "matched", "Matched"
        DISPUTED = "disputed", "Disputed"

    statement = models.ForeignKey(
        BankStatement,
        on_delete=models.CASCADE,
        related_name="lines",
    )
    value_date = models.DateField()
    description = models.CharField(max_length=512)
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    currency = models.CharField(
        max_length=8,
        choices=ISO4217_CHOICES,
        default=DEFAULT_CURRENCY,
    )
    match_status = models.CharField(
        max_length=12,
        choices=MatchStatus.choices,
        default=MatchStatus.UNMATCHED,
    )
    matched_execution = models.ForeignKey(
        DisbursementExecution,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="bank_lines_matched",
    )
    matched_gl_entry = models.ForeignKey(
        GLEntry,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="bank_lines_matched",
    )

    class Meta:
        ordering = ["value_date", "pk"]
        indexes = [
            models.Index(fields=["match_status", "value_date"]),
        ]

    def __str__(self):
        return f"{self.value_date} {self.amount} {self.currency} — {self.description[:60]}"
