import enum
import uuid
from datetime import timedelta
from decimal import Decimal
from typing import Any

import recurrence.fields
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import (
    MaxValueValidator,
    MinValueValidator,
    RegexValidator,
)
from django.db import models
from django.db.models.functions import Lower
from django.utils import timezone
from djmoney.models.fields import MoneyField
from djmoney.money import Money
from encrypted_fields.fields import EncryptedCharField
from ordered_model.models import OrderedModel

from common.tokens import generate_token

User = get_user_model()

# https://stackoverflow.com/questions/224462/storing-money-in-a-decimal-column-what-precision-and-scale/224866#224866
#
MAX_DIGITS = 14
DECIMAL_PLACES = 2


####################################################################
#
def get_default_currency() -> str:
    """Return the project-wide default currency from settings.

    Used as a callable default for MoneyField 'default_currency' and
    CharField defaults so that changing DEFAULT_CURRENCY in settings
    does not generate new migrations.
    """
    return settings.DEFAULT_CURRENCY


########################################################################
########################################################################
#
class EventKind(enum.StrEnum):
    """Discriminator for the two funding event types.

    Used by the funding service to distinguish FUND events (move money
    from unallocated into a budget, or into a recurring budget's fill-up
    goal) from RECUR events (move money from a fill-up goal into its
    associated recurring budget).  Lives here rather than in the
    funding service so that FundingEventOccurrence.kind can be typed
    against the same enum that the service uses.
    """

    FUND = "fund"
    RECUR = "recur"


####################################################################
#
def get_default_zero() -> Money:
    """Return a zero Money value in the project-wide default currency.

    Used as a callable default for MoneyField 'default' so that both
    'default' and 'default_currency' are callables -- a requirement
    of django-money when 'default_currency' is callable.
    """
    return Money("0.00", settings.DEFAULT_CURRENCY)


########################################################################
########################################################################
#
class MoneyPoolBaseClass(models.Model):
    # NOTE: We are using UUID's as pseudo-primary keys.  This was originally
    # because some of the data we import from simple bank json files has UUID's
    # as identifiers and I thought it best to leverage that in my models and
    # continue to use UUID's.. but without some of the tradeoffs of UUID's as
    # primary keys.
    #
    # See: https://www.stevenmoseley.com/blog/tech/uuid-primary-keys-django-rest-framework-2-steps
    #
    pkid = models.BigAutoField(primary_key=True, editable=False)
    id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)
    modified_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


########################################################################
########################################################################
#
class Bank(MoneyPoolBaseClass):
    """
    We are dealing with your money stored in bank accounts in the
    banks you have accounts with.
    """

    name = models.CharField(max_length=200)

    def __str__(self) -> str:
        return self.name

    routing_number = models.CharField(
        max_length=9, null=True, blank=True, default=None, unique=True
    )

    # ISO 4217 currency code.  Bank accounts created under this bank
    # inherit this currency by default.
    #
    default_currency = models.CharField(
        max_length=3,
        default=get_default_currency,
        help_text="ISO 4217 currency code (e.g. USD, EUR, GBP).",
    )


########################################################################
########################################################################
#
class BankAccount(MoneyPoolBaseClass):
    """
    This app is about budgeting your money but as a view in to your
    bank account's money. Thus the fundamental aspect of a Budget is
    which bank account it is tied to.
    """

    #####################################################################
    #
    class BankAccountType(models.TextChoices):
        CHECKING = "C", "Checking"
        SAVINGS = "S", "Savings"
        CREDIT_CARD = "X", "Credit Card"

    #
    #####################################################################

    name = models.CharField(max_length=200)
    bank = models.ForeignKey(
        Bank, to_field="id", on_delete=models.CASCADE, editable=False
    )
    owners: "models.ManyToManyField[Any, Any]" = models.ManyToManyField(User)

    # max_length=32 comfortably covers OFX ACCTID (spec max A-22),
    # SWIFT account-identifier segments, and the longer internal IDs
    # fintech providers (Apple, etc.) use in their OFX exports.
    account_number = EncryptedCharField(
        max_length=32, null=True, blank=True, default=None, unique=True
    )

    account_type = models.CharField(
        max_length=1,
        choices=BankAccountType.choices,
        default=BankAccountType.CHECKING,
    )

    # ISO 4217 currency code for this account.  Specified by the user
    # on creation; defaults to the bank's default_currency if omitted.
    # Immutable after creation -- the pre_save signal propagates this
    # to the balance MoneyField currencies.
    #
    currency = models.CharField(
        max_length=3,
        default=get_default_currency,
        help_text="ISO 4217 currency code (e.g. USD, EUR, GBP).",
    )

    # Available Balance is the amount available for withdrawal and may include
    # pending transactions not yet posted to your account.
    #
    # Posted Balance is the account's balance after items have posted to your
    # accounts as deposits or withdrawals.
    #
    posted_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        help_text="Posted Balance does not include pending debits.",
        editable=False,
    )
    available_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        help_text="Available Balance has pending debits deducted.",
        editable=False,
    )

    # All the money in a bank account is split across all budgets in the bank
    # account. We need one budget that always exists that can not be
    # archived. This is the budget of money that has not been allocated to any
    # other budget.
    #
    # This budget is created if this foreign key is null in the bank account's
    # post_save signal.
    #
    unallocated_budget = models.ForeignKey(
        "Budget",
        models.SET_NULL,
        to_field="id",
        blank=True,
        null=True,
    )

    # `link_aliases` are strings that may show up in Transactions in other
    # BankAccounts that indicate some transfer to/from this bank account to
    # that other bank account. This way we can look at the "description" on a
    # Transaction, and see that it is a transfer between bank accounts and this
    # lets us figure what bank account on the other side of the transaction
    # is. Used by the cross-account transaction linker (moneypools.linking) to
    # resolve merchant- visible names like "CHASE CREDIT CRD" or "APPLECARD
    # GSBANK" back to the BankAccount they represent. Matched case-insensitive
    # as substrings, so short, distinctive fragments work best. This
    # supplements the automatic matches against ``name`` and the last-4 of
    # ``account_number``.
    #
    link_aliases = models.JSONField(
        default=list,
        blank=True,
        help_text=(
            "Substrings that may appear in a counterpart "
            "transaction's description to identify this account "
            '(e.g. "CHASE CREDIT CRD"). Case-insensitive.'
        ),
    )

    # When False, scheduled funding/recurrence runs skip this account.
    # The "Run funding now" REST endpoint ignores this flag -- the user
    # can always trigger funding manually.
    #
    auto_funding_enabled = models.BooleanField(
        default=True,
        help_text=(
            "When enabled (the default), scheduled funding and recurrence "
            "events run automatically for this account.  Disable to opt out "
            "of automation and drive funding entirely from the 'Run funding "
            "now' button."
        ),
    )

    # Import-freshness tracking.  Set by the mark-imported endpoint after
    # each successful import run.
    #
    last_imported_at = models.DateTimeField(
        null=True,
        blank=True,
        default=None,
        help_text="Wall-clock time of the most recent completed import for this account.",
    )
    last_posted_through = models.DateField(
        null=True,
        blank=True,
        default=None,
        help_text=(
            "Latest posted_date seen in the most recent import batch. "
            "The funding engine will not process events dated after this value."
        ),
    )

    #####################################################################
    #
    @property
    def lock_key(self) -> str:
        """Return the Redis lock key for this bank account."""
        return f"bank_account:{self.id}"

    ####################################################################
    #
    def __str__(self) -> str:
        return f"{self.name} ({self.bank.name}) [{str(self.id)[:8]}]"


########################################################################
########################################################################
#
class TransactionCategoryQuerySet(models.QuerySet):
    """QuerySet for TransactionCategory with visibility filtering."""

    ####################################################################
    #
    def visible_to(self, user: Any) -> "models.QuerySet[Any]":
        """Return the categories the given user may see.

        A category is visible when any of these hold:

        1. It is a global category (owner is NULL).
        2. The user owns it.
        3. Its owner co-owns at least one bank account with the user
           (custom categories are shared across joint accounts).
        4. It is referenced by a Transaction or TransactionAllocation
           on an account the user owns -- the "grandfather" clause that
           keeps referenced categories readable after account sharing
           ends, without tombstones or snapshots.

        Args:
            user: The requesting User.

        Returns:
            A distinct queryset of visible categories.
        """
        return self.filter(
            models.Q(owner__isnull=True)
            | models.Q(owner=user)
            | models.Q(owner__bankaccount__owners=user)
            | models.Q(transactions__bank_account__owners=user)
            | models.Q(allocations__transaction__bank_account__owners=user)
        ).distinct()


########################################################################
########################################################################
#
class TransactionCategory(MoneyPoolBaseClass):
    """What a transaction (or a split of one) was spent on.

    A flat, two-part taxonomy: 'group' is the top-level bucket (e.g.
    'Food & Drink') and 'name' is the leaf (e.g. 'Groceries').  Every
    row is a leaf -- "group-level" semantics are expressed as
    'category__group' filters, and a provider's single-level category
    ("Travel : Travel") maps to a row whose group equals its name.

    Rows with owner NULL are the global base set shared by all users
    (seeded by migration, managed via the django-admin).  Rows with an
    owner are user-created custom categories, visible to the owner and
    to anyone who co-owns a bank account with them (see
    TransactionCategoryQuerySet.visible_to).

    A NULL category FK on Transaction / TransactionAllocation means
    "unassigned" -- there is no sentinel row.

    Uniqueness is case-insensitive on (group, name), enforced once for
    the global namespace and once per owner.  full_name is derived from
    the columns; there is no stored full_name.
    """

    group = models.CharField(max_length=64)
    name = models.CharField(max_length=64)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        default=None,
        related_name="transaction_categories",
        help_text="NULL for a global category shared by all users.",
    )
    archived = models.BooleanField(
        default=False,
        help_text=(
            "Archived categories are hidden from pickers but remain "
            "valid on existing transactions and allocations."
        ),
    )

    objects = TransactionCategoryQuerySet.as_manager()

    class Meta:
        verbose_name_plural = "transaction categories"
        ordering = ["group", "name"]
        constraints = [
            models.UniqueConstraint(
                Lower("group"),
                Lower("name"),
                condition=models.Q(owner__isnull=True),
                name="transaction_category_unique_global",
            ),
            models.UniqueConstraint(
                Lower("group"),
                Lower("name"),
                models.F("owner"),
                condition=models.Q(owner__isnull=False),
                name="transaction_category_unique_per_owner",
            ),
        ]

    ####################################################################
    #
    @property
    def full_name(self) -> str:
        """The canonical display form: '{group} : {name}'."""
        return f"{self.group} : {self.name}"

    ####################################################################
    #
    def __str__(self) -> str:
        scope = "global" if self.owner_id is None else str(self.owner)
        return f"{self.full_name} [{scope}]"


########################################################################
########################################################################
#
class MerchantIntermediaryPattern(MoneyPoolBaseClass, OrderedModel):
    """A regex identifying one payment platform's card-descriptor prefix.

    Card networks render "soft descriptors" as `PREFIX*detail` (Square
    "SQ *", Toast "TST*", DoorDash "DD *DOORDASH ..."); some prefixes
    belong to a POS processor (you were physically at the store) and
    some to a marketplace/aggregator (the store is elsewhere), but
    either way the raw descriptor hides the real store behind a
    platform token. Rows here are tried in `order` against a
    transaction's raw description by
    moneypools.service.merchant_enrichment; the first match wins.
    Seeded from platforms observed in real BofA data (migration);
    admin-editable so a new platform or a correction needs no code
    release -- see the module docstring in service/merchant_enrichment
    for why this stays a plain lookup table rather than growing
    resolution-order/visibility rules like TransactionCategory once
    had.

    `order` (django-ordered-model) is a real, gap-free sequence rather
    than a sparse priority integer -- reordered from the django-admin
    (drag/move-up-down) instead of hand-editing numbers.
    """

    pattern = models.CharField(
        max_length=200,
        help_text=(
            "Case-insensitive regex matched against the transaction's "
            "raw_description. An optional named group 'store' captures "
            "the text following the platform's prefix."
        ),
    )
    token = models.CharField(
        max_length=32,
        unique=True,
        help_text=(
            "Stable slug stored in Transaction.merchant_intermediary "
            "(e.g. 'square')."
        ),
    )
    display_name = models.CharField(
        max_length=64,
        help_text=(
            "Human-readable platform name used in the composed "
            "description (e.g. 'Square')."
        ),
    )
    active = models.BooleanField(default=True)

    class Meta(OrderedModel.Meta):
        verbose_name_plural = "merchant intermediary patterns"

    ####################################################################
    #
    def __str__(self) -> str:
        return f"{self.token} ({self.pattern})"


########################################################################
########################################################################
#
# NOTE: we must make sure that there is always a 'safe to spend'
# budget. It is displayed somewhat specially.
#
class Budget(MoneyPoolBaseClass):
    """
    The core of the MoneyPools system is the budget. Modeled somewhat
    after what Simple Bank expressed as "goals" and "expenses" but
    tweaked for how we actually used these systems with more
    automation around recurring budgets (what Simple called
    "expenses") and non-recurring budgets (ie: "Goals") and whether or
    not they had a budget that money was filled up on completion of a
    recurring budget.
    """

    #####################################################################
    #
    class BudgetType(models.TextChoices):
        """
        Goal -> money accumulates and once it reaches the target_balance
                the goal is complete (and further automatic accumulation
                of money does not happen.)

        Recurring -> money accumulates and once it reaches the
                target_balance it is complete. However, if the amount of
                money in the budget falls below the target_balance, it
                will start accuring money again on its schedule.
        """

        GOAL = "G", "Goal"
        RECURRING = "R", "Recurring"
        ASSOCIATED_FILLUP_GOAL = "A", "Associated Fill-up Goal"
        CAPPED = "C", "Capped"

    #
    #####################################################################

    #####################################################################
    #
    class FundingType(models.TextChoices):
        """
        How is a budget funded? Budgets are credited some time on the day
        of their specified funding schedule. How much money is
        credited in to a budget is of tehse types:

        Target Date -> You want the goal to be funded by the target
            date. (the amount left to fund the budget divided by the
            number of days in the funding schedule before the target
            date)

        Fixed Amount -> You want the goal to be credited a fixed
            amount on its funding schedule dates.
        """

        TARGET_DATE = "D", "Target Date"
        FIXED_AMOUNT = "F", "Fixed Amount"

    #
    #####################################################################

    name = models.CharField(max_length=200)
    bank_account = models.ForeignKey(
        BankAccount, to_field="id", on_delete=models.CASCADE, editable=False
    )
    balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
    )
    # Running net of all InternalTransactions touching this budget
    # (credits minus debits).  Meaningful only for Goal budgets; left at 0
    # for all other types.  Updated by internal_transaction_svc.create/delete.
    #
    funded_amount = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        help_text=(
            "For Goal budgets: running net of all ITX credits minus debits. "
            "Unused for other types."
        ),
    )
    target_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
    )
    budget_type = models.CharField(
        max_length=1,
        choices=BudgetType.choices,
        default=BudgetType.GOAL,
    )
    funding_type = models.CharField(
        max_length=1,
        choices=FundingType.choices,
        default=FundingType.TARGET_DATE,
    )

    # Only relevant for Goal budgets with FundingType 'target_date'.
    # The date by which the budget should be fully funded.  Recurring
    # budgets have no target_date; their upcoming refresh date is
    # computed from recurrence_schedule and last_recurrence_on (see
    # funding.next_recurrence_date) -- the schedule's DTSTART is only
    # the rule's anchor and never advances.
    #
    target_date = models.DateField(null=True, blank=True)

    # Only relevant if the FundingType is 'fixed_amount'.  Specifies
    # how much is credited to the budget on each funding event.
    #
    funding_amount = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        null=True,
        blank=True,
        default=None,
        default_currency=get_default_currency,
        help_text="Amount credited per funding event (Fixed Amount funding type only).",
    )

    # The fill-up goal is an ASSOCIATED_FILLUP_GOAL child budget created
    # automatically alongside every Recurring budget.
    #
    # Funding flow:
    #   funding_schedule fires  -> unallocated -> fillup_goal  (accumulation)
    #   recurrence_schedule fires -> fillup_goal -> recurring  (top-up to target)
    #
    # On the recurrence date, min(gap, fillup.balance) is transferred from the
    # fill-up into the recurring budget, where gap = target_balance - balance.
    # Any surplus stays in the fill-up for the next cycle.
    #
    fillup_goal = models.ForeignKey(
        "self", to_field="id", null=True, blank=True, on_delete=models.SET_NULL
    )

    archived = models.BooleanField(default=False, editable=False)
    archived_at = models.DateTimeField(null=True, blank=True, editable=False)
    paused = models.BooleanField(
        default=False,
        help_text="A paused budget does not get automatically funded on its schedule.",
    )

    # Whether this budget has reached its funding target and should not
    # receive further automatic funding.
    #
    # The funding task checks this flag before crediting a budget.
    # Clearing semantics differ by type:
    #   Goal (G)    -- set True when balance >= target; never cleared.
    #                  Once a goal is funded it stays funded even if
    #                  money is later spent from it.
    #   Recurring (R) -- set True by the recurrence handler when the
    #                  recurring budget reaches its target after a
    #                  fillup_goal -> recurring transfer; cleared at
    #                  the start of each new recurrence cycle (cycle reset).
    #   Capped (C)  -- set True when balance >= target; cleared
    #                  automatically when balance drops below target
    #                  (via pre_save signal).  This produces the
    #                  "perpetual top-up to a cap" behavior.
    #
    # The unallocated budget always has complete=False (it is never
    # funded automatically, so the flag is irrelevant, but False is
    # the safe default).
    #
    complete = models.BooleanField(
        default=False,
        help_text=(
            "True when this budget has reached its target and should not "
            "be funded further.  Managed by signals and funding tasks; "
            "do not set manually."
        ),
    )
    funding_schedule = recurrence.fields.RecurrenceField()

    # Funding-engine progress pointers.  Updated by the funding service after
    # each successfully processed event; used as the exclusive lower bound on
    # the next event-enumeration query so events are never double-processed.
    #
    last_funded_on = models.DateField(
        null=True,
        blank=True,
        default=None,
        help_text="Date of the most recently processed funding event for this budget.",
    )
    last_recurrence_on = models.DateField(
        null=True,
        blank=True,
        default=None,
        help_text=(
            "Date of the most recently processed recurrence event. "
            "Only meaningful for Recurring budgets."
        ),
    )

    # Only relevant for 'recurring' budgets with FundingType target_date.  This
    # is the interval at which we need this budget to be completed. So, if you
    # have a bill you need to pay once a month, by the first of the month you
    # would set your recurrence schedule to be "the first of each month."
    # Things like rent, regular payments, etc. Another example is if you have a
    # service you subscribe to that is renewed every year.. you would set the
    # recurrence schedule to be shortly before that subscription is due.  The
    # idea is that the budget is refreshed just after midnight on its
    # recurrence schedule.
    #
    # NOTE: the REST API restricts this field to a cycle-plus-anchor
    # grammar (one RRULE, FREQ + optional INTERVAL, day anchored by
    # DTSTART; no BY*/COUNT/UNTIL).  See
    # BudgetSerializer.validate_recurrence_schedule and docs/funding.md
    # section 3.2.  The model field itself accepts any recurrence so
    # the admin and import commands remain unrestricted.
    #
    recurrence_schedule = recurrence.fields.RecurrenceField(null=True)

    image = models.ImageField(
        upload_to="budget_images/%Y-%m-%d/",
        height_field="image_height",
        width_field="image_width",
        null=True,
        blank=True,
    )
    image_height = models.IntegerField(null=True, editable=False, blank=True)
    image_width = models.IntegerField(null=True, editable=False, blank=True)
    memo = models.TextField(max_length=512, null=True, blank=True)

    # Auto-spend rules: a JSON list of matcher strings.  When a new
    # transaction allocation matches one of a budget's entries, the
    # spend is automatically routed to that budget.
    #
    # Each entry is currently a transaction-category full name in the
    # canonical '{group} : {name}' form (e.g. 'Food & Drink :
    # Groceries').  Matching is case-insensitive and
    # whitespace-normalized via service.categories.  Strings rather
    # than category UUIDs so that export/import files stay portable
    # across deployments.
    #
    # The string form deliberately leaves room for other matcher kinds
    # later without a schema change -- e.g. a future 'tag:groceries'
    # entry matching merchants tagged '#groceries' once merchants and
    # tags exist.  The planned budget auto-allocation-rules feature
    # (match on merchant name / category / MCC) will replace or
    # formalize this field; keep it loose until then.
    #
    # NOTE: Need to enforce in pre-save that only one budget in an
    #       account has a given entry selected.
    #
    auto_spend = models.JSONField(
        default=list,
        blank=True,
        help_text=(
            "List of matcher strings; currently transaction-category "
            "full names ('{group} : {name}').  Spend matching an entry "
            "is auto-routed to this budget."
        ),
    )

    ####################################################################
    #
    def __str__(self) -> str:
        return f"{self.name} ({self.bank_account.name})"

    ####################################################################
    #
    def clean(self) -> None:
        """Validate budget type / funding type consistency.

        Raises:
            ValidationError: If any field combination violates section 11
                of docs/funding.md.
        """
        super().clean()
        errors: dict[str, str] = {}
        ft = self.funding_type
        FT = Budget.FundingType

        match self.budget_type:
            case Budget.BudgetType.RECURRING:
                if ft != FT.TARGET_DATE:
                    errors["funding_type"] = (
                        "Recurring budgets must use TARGET_DATE funding."
                    )
                if self.target_date is not None:
                    errors["target_date"] = (
                        "Recurring budgets must not have a target_date."
                    )
                if not self.recurrence_schedule:
                    errors["recurrence_schedule"] = (
                        "Recurring budgets must have a recurrence_schedule."
                    )
                if self.funding_amount is not None:
                    errors["funding_amount"] = (
                        "Recurring budgets must not have a funding_amount."
                    )

            case Budget.BudgetType.CAPPED:
                if ft != FT.FIXED_AMOUNT:
                    errors["funding_type"] = (
                        "Capped budgets must use FIXED_AMOUNT funding."
                    )
                if self.target_date is not None:
                    errors["target_date"] = (
                        "Capped budgets must not have a target_date."
                    )
                if self.funding_amount is None:
                    errors["funding_amount"] = (
                        "Capped budgets must have a funding_amount."
                    )
                if self.recurrence_schedule:
                    errors["recurrence_schedule"] = (
                        "Capped budgets must not have a recurrence_schedule."
                    )
                if self.fillup_goal_id is not None:
                    errors["fillup_goal"] = (
                        "Only Recurring budgets may have a fillup_goal."
                    )

            case Budget.BudgetType.GOAL:
                if ft == FT.TARGET_DATE and self.target_date is None:
                    errors["target_date"] = (
                        "Goal budgets with TARGET_DATE funding require a "
                        "target_date."
                    )
                if ft == FT.FIXED_AMOUNT and self.funding_amount is None:
                    errors["funding_amount"] = (
                        "Goal budgets with FIXED_AMOUNT funding require a "
                        "funding_amount."
                    )
                if self.recurrence_schedule:
                    errors["recurrence_schedule"] = (
                        "Goal budgets must not have a recurrence_schedule."
                    )
                if self.fillup_goal_id is not None:
                    errors["fillup_goal"] = (
                        "Only Recurring budgets may have a fillup_goal."
                    )

        if errors:
            raise ValidationError(errors)

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=models.Q(budget_type="R", funding_type="D")
                | ~models.Q(budget_type="R"),
                name="budget_recurring_must_be_target_date",
            ),
            models.CheckConstraint(
                condition=models.Q(budget_type="C", funding_type="F")
                | ~models.Q(budget_type="C"),
                name="budget_capped_must_be_fixed_amount",
            ),
            models.CheckConstraint(
                condition=(
                    models.Q(budget_type="R", target_date__isnull=True)
                    | models.Q(budget_type="C", target_date__isnull=True)
                    | ~models.Q(budget_type__in=["R", "C"])
                ),
                name="budget_recurring_capped_no_target_date",
            ),
        ]

    ####################################################################
    #
    @property
    def lock_key(self) -> str:
        """Return the Redis lock key for this budget."""
        return f"budget:{self.id}"


########################################################################
########################################################################
#
class FundingEventOccurrence(MoneyPoolBaseClass):
    """A single scheduled funding or recurrence event for a budget.

    Materialized by the funding service when an event becomes due so that
    partial or skipped events have a place to live until they are either
    completed on a later run or superseded by the next event of the same
    kind for the same budget.

    Lifecycle:
        PENDING  -- materialized, no transfer attempted yet.
        PARTIAL  -- transfer attempted; some funds moved but the strategy's
                    intended amount was not fully covered.  Eligible for
                    retry on subsequent runs (manual or scheduled) until
                    superseded.
        COMPLETE -- intended amount fully transferred; no further action.
        SKIPPED  -- closed without completion.  Set when (a) a newer
                    occurrence of the same (budget, kind) is materialized
                    while this one is still PENDING/PARTIAL, or (b) the
                    budget is paused at the time the event would fire.

    The 'intended' amount is NOT stored on this row -- it is recomputed
    via the budget's strategy on every attempt so that mid-cycle edits to
    target_balance, funding_amount, etc. take effect immediately.  The
    'funded so far' figure is derived by summing matching
    InternalTransaction rows (system_event_kind, system_event_date).
    """

    class Status(models.TextChoices):
        PENDING = "PENDING", "Pending"
        PARTIAL = "PARTIAL", "Partial"
        COMPLETE = "COMPLETE", "Complete"
        SKIPPED = "SKIPPED", "Skipped"

    budget = models.ForeignKey(
        Budget,
        to_field="id",
        on_delete=models.CASCADE,
        related_name="funding_occurrences",
    )
    kind = models.CharField(
        max_length=5,
        help_text=(
            'Funding event discriminator: "fund" or "recur".  Stored as '
            "the EventKind string value; not exposed in user-facing forms "
            "so no choices= is set."
        ),
    )
    scheduled_date = models.DateField(
        help_text="Calendar date the event was scheduled to fire.",
    )
    status = models.CharField(
        max_length=8,
        choices=Status.choices,
        default=Status.PENDING,
    )
    completed_at = models.DateTimeField(
        null=True,
        blank=True,
        default=None,
        help_text=(
            "Wall-clock time the occurrence reached COMPLETE.  Null while "
            "PENDING/PARTIAL/SKIPPED."
        ),
    )

    ####################################################################
    #
    def __str__(self) -> str:
        return (
            f"{self.budget.name} {self.kind} "
            f"{self.scheduled_date} ({self.get_status_display()})"
        )

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["budget", "kind", "scheduled_date"],
                name="funding_event_occ_unique_per_event",
            ),
        ]
        indexes = [
            # Cheap "what's outstanding?" lookup for a budget or its
            # parent account: find PENDING/PARTIAL occurrences ordered
            # by date.  Partial index keeps it small as COMPLETE rows
            # accumulate.
            models.Index(
                fields=["budget", "scheduled_date"],
                condition=models.Q(status__in=["PENDING", "PARTIAL"]),
                name="funding_event_occ_outstanding",
            ),
        ]


########################################################################
########################################################################
#
class ServiceOnlyQuerySet(models.QuerySet):
    """QuerySet that blocks bulk writes bypassing the service layer.

    Transaction, TransactionAllocation, and InternalTransaction maintain
    strict balance invariants via Redis-locked service functions.
    bulk_create and bulk_update skip those code paths entirely, so they
    are disabled here. Use the corresponding service module instead.
    """

    def bulk_create(self, objs: Any, *args: Any, **kwargs: Any) -> Any:
        raise NotImplementedError(
            f"{self.model.__name__}.objects.bulk_create() is disabled. "
            "Use the service layer (moneypools.service) to create objects "
            "so that balance invariants and Redis locks are enforced."
        )

    def bulk_update(
        self, objs: Any, fields: Any, *args: Any, **kwargs: Any
    ) -> Any:
        raise NotImplementedError(
            f"{self.model.__name__}.objects.bulk_update() is disabled. "
            "Use the service layer (moneypools.service) to update objects "
            "so that balance invariants and Redis locks are enforced."
        )


ServiceOnlyManager = models.Manager.from_queryset(ServiceOnlyQuerySet)


########################################################################
########################################################################
#
class TransactionBaseClass(MoneyPoolBaseClass):
    amount = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=0,
        editable=False,
    )
    bank_account = models.ForeignKey(
        BankAccount, to_field="id", on_delete=models.CASCADE, editable=False
    )

    class Meta:
        abstract = True


########################################################################
########################################################################
#
class Transaction(TransactionBaseClass):
    """
    A transaction detailing a credit/debit from some 3rd party

    NOTE: if this is associated with a budget, deleting the budget
    moves it back to the 'unallocated' budget.
    """

    objects = ServiceOnlyManager()

    #####################################################################
    #
    class TransactionType(models.TextChoices):
        """
        An extended list of transaction types based on the original data
        in our existing bank accounts downloaded transaction logs.
        """

        SIGNATURE_PURCHASE = "signature_purchase", "Signature Purchase"
        ACH = "ach", "ACH"
        ROUND_UP_TRANSFER = "round-up_transfer", "Round-up Transfer"
        PROTECTED_GOAL_ACCOUNT_TRANSFER = (
            "protected_goal_account_transfer",
            "Protected Goal Account Transfer",
        )
        FEE = "fee", "Fee"
        PIN_PURCHASE = "pin_purchase", "Pin Purchase"
        SIGNATURE_CREDIT = "signature_credit", "Signature Credit"
        INTEREST_CREDIT = "interest_credit", "Interest Credit"
        SHARED_TRANSFER = "shared_transfer", "Shared Transfer"
        COURTESY_CREDIT = "courtesy_credit", "Courtesy Credit"
        ATM_WITHDRAWAL = "atm_withdrawal", "ATM Withdrawal"
        BILL_PAYMENT = "bill_payment", "Bill Payment"
        BANK_GENERATED_CREDIT = (
            "bank_generated_credit",
            "Bank Generated Credit",
        )
        WIRE_TRANSFER = "wire_transfer", "Wire Transfer"
        CHECK_DEPOSIT = "check_deposit", "Check Deposit"
        CHECK = "check", "Check"
        C2C = "c2c", "c2c"
        MIGRATION_INTERBANK_TRANSFER = (
            "migration_interbank_transfer",
            "Migration Interbank Transfer",
        )
        BALANCE_SWEEP = "balance_sweep", "Balance Sweep"
        ACH_REVERSAL = "ach_reversal", "ACH Reversal"
        ADJUSTMENT = "adjustment", "Adjustment"
        SIGNATURE_RETURN = "signature_return", "Signature return"
        FX_ORDER = "fx_order", "FX Order"
        NOT_SET = "", "--------"

    #
    #####################################################################

    # TODO: The `party` field is something that is derived from the
    # description in post processing after the Transaction has been
    # created. The desire is to have this be a foreign key relation or a set
    # of standardized names so we can easily say "all transactions by this
    # party"
    #
    party = models.CharField(
        max_length=300, null=True, blank=True, editable=False
    )
    # The bank-supplied settlement / posting date.  Always set from the
    # bank feed; never derived.
    #
    posted_date = models.DateTimeField(editable=False)

    # The actual purchase / transaction date.  Derived from the embedded
    # MM/DD pattern in raw_description when possible (see
    # description_utils.parse_transaction_date); falls back to posted_date
    # when no parseable date is found or the parsed date is outside the
    # sanity window.
    #
    transaction_date = models.DateTimeField(null=False, editable=False)
    transaction_type = models.CharField(
        max_length=32, choices=TransactionType.choices
    )

    # `pending` is a state we get from the bank. It basically means that the
    # amount may change until the state changes from `pending` to
    # `posted`. Also the `posted`
    #
    # The `available_balance` will always update with the amount of this
    # transaction. The `posted_balance` will only update when the transaction
    # is no longer pending.
    #
    # XXX Since `posted` is the final state maybe this should be `posted`
    #     instead of `pending` and we reverse the logic on when to apply it to
    #     the `posted_balance`. At least then the names all match where as now
    #     `pending` means `affects available` and `posted` means `affects
    #     available and posted `
    #
    pending = models.BooleanField(default=False, editable=False)
    memo = models.TextField(max_length=512, null=True, blank=True)
    raw_description = models.TextField(max_length=512, editable=False)

    # Opaque identifier assigned by the bank for this transaction. When set
    # it is the same value for both the pending and settled versions of the
    # same transaction, enabling reliable dedup across state transitions.
    # Null for transactions imported before this field was added or from
    # sources that do not supply a bank-side ID (CSV exports, OFX).
    #
    bank_transaction_id = models.CharField(
        max_length=256,
        null=True,
        blank=True,
        editable=False,
        db_index=True,
    )

    # TODO: Initial value of the description is a cleaned up version of the
    #       raw_description. It is added in post processing at the same time
    #       that `party` is derived. Initially it is set to the same value as
    #       'raw_description'
    #
    # NOTE: this is filled in via the pre_save signal in ./signals.py
    #
    description = models.TextField(max_length=512)

    # Linked counterpart on another account. For example, a credit card
    # payment appears as a debit on checking and a credit on the card.
    # Populated opportunistically by the import pipeline when both sides
    # are present. Never required.
    #
    linked_transaction = models.OneToOneField(
        "self",
        to_field="id",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="linked_from",
    )

    # What this transaction was spent on.  NULL means unassigned.
    # Seeded from the provider's category hint by the import pipeline
    # (via the resolver in service.categories) and user-editable
    # afterwards.  Allocations copy this value at allocation-creation
    # time; edits never propagate in either direction (a split
    # transaction's per-portion categories live on its allocations).
    #
    # SET_NULL is the DB-level safety net -- the API refuses to delete
    # a referenced category (409) and points at archiving instead.
    #
    category = models.ForeignKey(
        TransactionCategory,
        models.SET_NULL,
        to_field="id",
        null=True,
        blank=True,
        default=None,
        related_name="transactions",
    )

    # --- Merchant / transaction-details enrichment --------------------
    #
    # Populated by the transaction-details import pipeline (see
    # service/transaction_details.py).  The merchant identity fields
    # (name, category, MCC, virtual card, raw details) are
    # scraper-owned and read-only through the API.  The location
    # fields are user-editable: providers rarely supply more than
    # "CITY, ST", so users may refine or supply the merchant's actual
    # street address / map coordinates.  Enrichment writes location
    # fields only when they are empty -- user values always win.
    #
    # NOTE: a future Merchant model (the `party` TODO above) will be
    # backfilled from these columns; keep them queryable.
    #
    merchant_name = models.CharField(
        max_length=128, null=True, blank=True, editable=False
    )
    # Token of the payment platform/POS this purchase was routed
    # through (e.g. 'square', 'doordash'); NULL for a direct purchase.
    # See MerchantIntermediaryPattern / service/merchant_enrichment.
    merchant_intermediary = models.CharField(
        max_length=32, null=True, blank=True, editable=False, db_index=True
    )
    merchant_address = models.CharField(max_length=256, null=True, blank=True)
    merchant_city = models.CharField(max_length=128, null=True, blank=True)
    merchant_region = models.CharField(max_length=64, null=True, blank=True)
    merchant_country = models.CharField(
        max_length=2,
        null=True,
        blank=True,
        validators=[
            RegexValidator(
                r"^[A-Z]{2}$",
                "Must be an ISO 3166-1 alpha-2 country code.",
            )
        ],
    )
    merchant_latitude = models.DecimalField(
        max_digits=9,
        decimal_places=6,
        null=True,
        blank=True,
        validators=[
            MinValueValidator(Decimal("-90")),
            MaxValueValidator(Decimal("90")),
        ],
    )
    merchant_longitude = models.DecimalField(
        max_digits=9,
        decimal_places=6,
        null=True,
        blank=True,
        validators=[
            MinValueValidator(Decimal("-180")),
            MaxValueValidator(Decimal("180")),
        ],
    )
    # The provider's human-readable MCC description (e.g. "Grocery
    # Stores and Supermarkets") -- kept verbatim because the iso18245
    # package's description can lag the provider's.
    merchant_category = models.CharField(
        max_length=128, null=True, blank=True, editable=False
    )
    # ISO 18245 Merchant Category Code.  Any ^\d{4}$ value is stored
    # (the provider is authoritative); iso18245 validation only warns.
    merchant_category_code = models.CharField(
        max_length=4, null=True, blank=True, editable=False, db_index=True
    )
    # Arrives pre-masked from the provider ("XXXX-XXXX-XXXX-1439");
    # stored as-is, searchable by last-4 endswith.
    virtual_card_number = models.CharField(
        max_length=32, null=True, blank=True, editable=False
    )
    # Raw provider details dict, stored verbatim for provenance.
    # NULL means "never enriched" -- the sync-scrape details_needed
    # list is driven by this being NULL on a posted row.
    details = models.JSONField(null=True, blank=True, editable=False)
    # Set server-side when the user edits `description`; enrichment
    # never recomposes a user-edited description.
    description_user_edited = models.BooleanField(default=False)

    # Budget assignment is handled through TransactionAllocation objects.
    # A non-split transaction has one allocation; a split transaction has
    # multiple allocations whose amounts sum to the transaction amount.
    bank_account_posted_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        help_text="Posted Balance does not include pending debits.",
        editable=False,
    )
    bank_account_available_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        help_text="Available Balance has pending debits deducted.",
        editable=False,
    )
    image = models.ImageField(
        upload_to="transaction_images/%Y-%m-%d/",
        height_field="image_height",
        width_field="image_width",
        null=True,
        blank=True,
    )
    image_height = models.IntegerField(null=True, editable=False, blank=True)
    image_width = models.IntegerField(null=True, editable=False, blank=True)
    document = models.FileField(
        upload_to="transaction_documents/%Y-%m-%d/", null=True, blank=True
    )

    ####################################################################
    #
    @property
    def lock_key(self) -> str:
        """Return the Redis lock key for this transaction."""
        return f"transaction:{self.id}"


########################################################################
########################################################################
#
class InternalTransaction(TransactionBaseClass):
    """
    An internal transacation is moving money between budgets. It is
    all within the same bank account so the bank account's balance
    never changes.

    There is no 'pending' status either. This is typically a user
    initiated action and it is basically final. You make the internal
    transaction and money is debited from one budget and credited in
    another budget.

    We support having the amount of the transaction changed, and even
    having the transaction deleted to update the associated budgets'
    balances (this is done using django signals.. so note well: bulk
    deleting of transactions will not update budget accounts so if you
    are going to delete multiple internal transactions you need to
    delete them one by one.)
    """

    objects = ServiceOnlyManager()

    # The src and dst budgets are not editable. The internal
    # transaction is created and the balances on the related budgets
    # are immediately modified in the pre_save hook. If the actor
    # wishes to change the amounts in the src and dst budgets again
    # they will create a new internal transaction doing just that. Not
    # editing an internal transaction that has already been created.
    #
    # You might describe these as "write once" objects.
    #
    src_budget = models.ForeignKey(
        Budget,
        to_field="id",
        on_delete=models.CASCADE,
        editable=False,
        related_name="budget_debits",
    )
    dst_budget = models.ForeignKey(
        Budget,
        to_field="id",
        on_delete=models.CASCADE,
        editable=False,
        related_name="budget_credits",
    )
    actor = models.ForeignKey(User, on_delete=models.CASCADE, editable=False)
    # Economic datetime of the transfer -- when the funding conceptually
    # happened, not when the row was created.  Defaults to now so live
    # transfers are slotted correctly without any extra input.  Backfill
    # sets this to the period-boundary datetime so running-balance snapshots
    # place the ITx at the right point in the economic timeline.
    effective_date = models.DateTimeField(editable=False)
    src_budget_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        editable=False,
    )
    dst_budget_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        editable=False,
    )

    # Populated iff actor == funding_system_user().  Used by the engine
    # to compute already_moved (section 7) and state-at-start-of-day
    # rollback (section 6) in docs/funding.md.
    #
    class SystemEventKind(models.TextChoices):
        FUND = "F", "Fund"
        RECUR = "R", "Recur"

    system_event_kind = models.CharField(
        max_length=1,
        choices=SystemEventKind.choices,
        null=True,
        blank=True,
        default=None,
        editable=False,
    )
    system_event_date = models.DateField(
        null=True,
        blank=True,
        default=None,
        editable=False,
    )

    ####################################################################
    #
    def clean(self) -> None:
        """Validate that src_budget and dst_budget are different.

        Raises:
            ValidationError: If the source and destination budget are
                the same object.
        """
        super().clean()
        if (
            self.src_budget_id
            and self.dst_budget_id
            and (self.src_budget_id == self.dst_budget_id)
        ):
            raise ValidationError(
                "Source and destination budgets must be different."
            )

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=~models.Q(src_budget_id=models.F("dst_budget_id")),
                name="internal_transaction_src_dst_different",
            ),
        ]


########################################################################
########################################################################
#
class TransactionAllocation(MoneyPoolBaseClass):
    """
    Maps a portion of a transaction's amount to a budget.

    Every transaction has at least one allocation. A non-split transaction
    has exactly one allocation whose amount equals the transaction amount.
    A split transaction has multiple allocations whose amounts sum to the
    transaction amount.

    Budget balance adjustments flow through allocations, not through the
    Transaction model directly. This gives a single code path for both
    split and non-split transactions.
    """

    objects = ServiceOnlyManager()

    transaction = models.ForeignKey(
        Transaction,
        to_field="id",
        on_delete=models.CASCADE,
        related_name="allocations",
        editable=False,
    )
    budget = models.ForeignKey(
        Budget,
        models.SET_NULL,
        to_field="id",
        null=True,
        related_name="transaction_allocations",
    )
    amount = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=0,
        editable=False,
    )
    budget_balance = MoneyField(
        max_digits=MAX_DIGITS,
        decimal_places=DECIMAL_PLACES,
        default=get_default_zero,
        default_currency=get_default_currency,
        editable=False,
    )
    # What this portion of the transaction was spent on.  Lives here
    # as well as on Transaction because a single purchase (e.g. Costco)
    # can contain groceries and home supplies allocated to different
    # budgets with different categories.  Copied from the transaction's
    # category at allocation-creation time (see
    # service.transaction_allocation.create); edits never propagate in
    # either direction afterwards.  NULL means unassigned.
    #
    # SET_NULL is the DB-level safety net -- the API refuses to delete
    # a referenced category (409) and points at archiving instead.
    #
    category = models.ForeignKey(
        TransactionCategory,
        models.SET_NULL,
        to_field="id",
        null=True,
        blank=True,
        default=None,
        related_name="allocations",
    )
    memo = models.TextField(max_length=512, null=True, blank=True)


########################################################################
########################################################################
#
class BankAccountInvitation(MoneyPoolBaseClass):
    """Tracks a pending or completed bank-account co-ownership invitation.

    An existing account owner invites someone (by email) to become a
    co-owner.  The invitee may or may not already have a mibudge account.

    Lifecycle:
      - pending:   invitation sent; invitee has not yet acted.
      - accepted:  invitee accepted; added to BankAccount.owners.
      - declined:  invitee explicitly declined.
      - cancelled: inviter cancelled before the invitee acted.
      - expired:   token TTL elapsed with no action (set by the service
                   layer or a periodic cleanup task -- rows themselves are
                   never deleted so an audit trail is preserved).

    Rows are never deleted (audit trail).
    """

    ####################################################################
    #
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        ACCEPTED = "accepted", "Accepted"
        DECLINED = "declined", "Declined"
        CANCELLED = "cancelled", "Cancelled"
        EXPIRED = "expired", "Expired"

    #
    ####################################################################

    bank_account = models.ForeignKey(
        BankAccount,
        to_field="id",
        on_delete=models.CASCADE,
        editable=False,
        related_name="invitations",
    )
    invited_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="sent_invitations",
    )
    invitee_email = models.EmailField(
        help_text="Email address the invitation was sent to. Immutable after creation.",
        editable=False,
    )
    # Populated at creation time via get_or_create_inactive_user().
    # May be NULL if the user record is later deleted (SET_NULL).
    invitee_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="received_invitations",
    )
    token = models.CharField(
        max_length=64,
        unique=True,
        default=generate_token,
        editable=False,
    )
    status = models.CharField(
        max_length=12,
        choices=Status.choices,
        default=Status.PENDING,
    )
    expires_at = models.DateTimeField()
    accepted_at = models.DateTimeField(null=True, blank=True)
    declined_at = models.DateTimeField(null=True, blank=True)
    cancelled_at = models.DateTimeField(null=True, blank=True)
    # Resend tracking: how many times the invitation email has been sent
    # (starts at 1 for the initial send) and when it was last sent.
    send_count = models.PositiveSmallIntegerField(default=1)
    last_sent_at = models.DateTimeField(default=timezone.now)

    class Meta:
        ordering = ["-created_at"]

    ####################################################################
    #
    @property
    def is_expired(self) -> bool:
        """True if the invitation token has passed its expiry."""
        return timezone.now() > self.expires_at

    @property
    def is_pending(self) -> bool:
        """True if the invitation is still awaiting a response."""
        return self.status == self.Status.PENDING

    @property
    def is_terminal(self) -> bool:
        """True if the invitation has reached a final state."""
        return self.status in (
            self.Status.ACCEPTED,
            self.Status.DECLINED,
            self.Status.CANCELLED,
            self.Status.EXPIRED,
        )

    ####################################################################
    #
    @classmethod
    def make(
        cls,
        bank_account: "BankAccount",
        invited_by: "Any",
        invitee_email: str,
        invitee_user: "Any",
    ) -> "BankAccountInvitation":
        """Create a new pending invitation with a pre-computed expiry."""
        expiry_days = settings.INVITATION_EXPIRY_DAYS
        return cls.objects.create(
            bank_account=bank_account,
            invited_by=invited_by,
            invitee_email=invitee_email,
            invitee_user=invitee_user,
            expires_at=timezone.now() + timedelta(days=expiry_days),
        )

    ####################################################################
    #
    @classmethod
    def pending_for_email(
        cls, email: str
    ) -> "models.QuerySet[BankAccountInvitation]":
        """Return all pending, non-expired invitations for the given email.

        Used by the acceptance page to display all open invitations for
        the same invitee (multi-invitation support).
        """
        return cls.objects.filter(
            invitee_email=email,
            status=cls.Status.PENDING,
            expires_at__gt=timezone.now(),
        ).select_related("bank_account", "bank_account__bank", "invited_by")
